Merge pull request #59 from pselamy/fix/55
feat: add CLI entry point with argparse (#55)
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
"""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
|
||||
|
||||
# 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) -> int:
|
||||
"""Run the main pipeline.
|
||||
|
||||
Args:
|
||||
settings: Application settings.
|
||||
dry_run: Whether to skip sending alerts.
|
||||
|
||||
Returns:
|
||||
Exit code.
|
||||
"""
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
pipeline = Pipeline(settings, dry_run=dry_run)
|
||||
|
||||
logger.info("Starting pipeline...")
|
||||
await pipeline.run()
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Tests for the CLI entry point."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from polymarket_insider_tracker.__main__ import (
|
||||
EXIT_CONFIG_ERROR,
|
||||
EXIT_SUCCESS,
|
||||
configure_logging,
|
||||
create_parser,
|
||||
main,
|
||||
print_banner,
|
||||
run_config_check,
|
||||
validate_config,
|
||||
)
|
||||
|
||||
|
||||
class TestCreateParser:
|
||||
"""Tests for argument parser creation."""
|
||||
|
||||
def test_parser_has_version(self):
|
||||
"""Parser should have version flag."""
|
||||
parser = create_parser()
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
parser.parse_args(["--version"])
|
||||
assert exc_info.value.code == 0
|
||||
|
||||
def test_parser_config_check(self):
|
||||
"""Parser should accept --config-check flag."""
|
||||
parser = create_parser()
|
||||
args = parser.parse_args(["--config-check"])
|
||||
assert args.config_check is True
|
||||
|
||||
def test_parser_log_level(self):
|
||||
"""Parser should accept --log-level option."""
|
||||
parser = create_parser()
|
||||
args = parser.parse_args(["--log-level", "DEBUG"])
|
||||
assert args.log_level == "DEBUG"
|
||||
|
||||
def test_parser_dry_run(self):
|
||||
"""Parser should accept --dry-run flag."""
|
||||
parser = create_parser()
|
||||
args = parser.parse_args(["--dry-run"])
|
||||
assert args.dry_run is True
|
||||
|
||||
def test_parser_health_port(self):
|
||||
"""Parser should accept --health-port option."""
|
||||
parser = create_parser()
|
||||
args = parser.parse_args(["--health-port", "9090"])
|
||||
assert args.health_port == 9090
|
||||
|
||||
def test_parser_default_values(self):
|
||||
"""Parser should have correct defaults."""
|
||||
parser = create_parser()
|
||||
args = parser.parse_args([])
|
||||
assert args.config_check is False
|
||||
assert args.log_level is None
|
||||
assert args.dry_run is False
|
||||
assert args.health_port is None
|
||||
|
||||
|
||||
class TestConfigureLogging:
|
||||
"""Tests for logging configuration."""
|
||||
|
||||
def test_configure_logging_info(self):
|
||||
"""Should configure logging at INFO level."""
|
||||
configure_logging("INFO")
|
||||
import logging
|
||||
|
||||
assert logging.getLogger().level == logging.INFO
|
||||
|
||||
def test_configure_logging_debug(self):
|
||||
"""Should configure logging at DEBUG level."""
|
||||
configure_logging("DEBUG")
|
||||
import logging
|
||||
|
||||
assert logging.getLogger().level == logging.DEBUG
|
||||
|
||||
|
||||
class TestPrintBanner:
|
||||
"""Tests for banner printing."""
|
||||
|
||||
def test_banner_contains_app_name(self, capsys):
|
||||
"""Banner should contain application name."""
|
||||
print_banner()
|
||||
captured = capsys.readouterr()
|
||||
assert "Polymarket Insider Tracker" in captured.out
|
||||
|
||||
def test_banner_contains_version(self, capsys):
|
||||
"""Banner should contain version."""
|
||||
print_banner()
|
||||
captured = capsys.readouterr()
|
||||
assert "v0.1.0" in captured.out
|
||||
|
||||
|
||||
class TestValidateConfig:
|
||||
"""Tests for configuration validation."""
|
||||
|
||||
def test_validate_config_success(self, monkeypatch):
|
||||
"""Should return settings on valid config."""
|
||||
monkeypatch.setenv("DATABASE_URL", "postgresql://localhost/test")
|
||||
|
||||
settings = validate_config()
|
||||
assert settings is not None
|
||||
|
||||
def test_validate_config_failure(self, monkeypatch, capsys):
|
||||
"""Should return None on invalid config."""
|
||||
# Clear any existing DATABASE_URL
|
||||
monkeypatch.delenv("DATABASE_URL", raising=False)
|
||||
|
||||
settings = validate_config()
|
||||
assert settings is None
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Configuration validation failed" in captured.err
|
||||
|
||||
|
||||
class TestRunConfigCheck:
|
||||
"""Tests for config check mode."""
|
||||
|
||||
def test_config_check_prints_summary(self, monkeypatch, capsys):
|
||||
"""Config check should print configuration summary."""
|
||||
monkeypatch.setenv("DATABASE_URL", "postgresql://localhost/test")
|
||||
|
||||
settings = validate_config()
|
||||
assert settings is not None
|
||||
|
||||
result = run_config_check(settings)
|
||||
assert result == EXIT_SUCCESS
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Configuration is valid!" in captured.out
|
||||
assert "Configuration:" in captured.out
|
||||
|
||||
|
||||
class TestMain:
|
||||
"""Tests for main entry point."""
|
||||
|
||||
def test_main_with_config_check(self, monkeypatch):
|
||||
"""Main should exit successfully with --config-check."""
|
||||
monkeypatch.setenv("DATABASE_URL", "postgresql://localhost/test")
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main(["--config-check"])
|
||||
|
||||
assert exc_info.value.code == EXIT_SUCCESS
|
||||
|
||||
def test_main_with_invalid_config(self, monkeypatch):
|
||||
"""Main should exit with config error on invalid config."""
|
||||
monkeypatch.delenv("DATABASE_URL", raising=False)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main([])
|
||||
|
||||
assert exc_info.value.code == EXIT_CONFIG_ERROR
|
||||
|
||||
def test_main_with_dry_run_and_config_check(self, monkeypatch):
|
||||
"""Main should handle dry-run with config-check."""
|
||||
monkeypatch.setenv("DATABASE_URL", "postgresql://localhost/test")
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main(["--config-check", "--dry-run"])
|
||||
|
||||
assert exc_info.value.code == EXIT_SUCCESS
|
||||
|
||||
@patch("polymarket_insider_tracker.__main__.run_pipeline")
|
||||
@patch("polymarket_insider_tracker.__main__.asyncio.run")
|
||||
def test_main_runs_pipeline(self, mock_asyncio_run, _mock_run_pipeline, monkeypatch):
|
||||
"""Main should run pipeline when not in config-check mode."""
|
||||
monkeypatch.setenv("DATABASE_URL", "postgresql://localhost/test")
|
||||
mock_asyncio_run.return_value = EXIT_SUCCESS
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main([])
|
||||
|
||||
assert exc_info.value.code == EXIT_SUCCESS
|
||||
mock_asyncio_run.assert_called_once()
|
||||
|
||||
|
||||
class TestIntegration:
|
||||
"""Integration tests for CLI invocation."""
|
||||
|
||||
def test_cli_help_option(self, capsys):
|
||||
"""CLI should display help with -h option."""
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main(["-h"])
|
||||
|
||||
assert exc_info.value.code == 0
|
||||
captured = capsys.readouterr()
|
||||
assert "polymarket-insider-tracker" in captured.out
|
||||
assert "--config-check" in captured.out
|
||||
assert "--dry-run" in captured.out
|
||||
assert "--log-level" in captured.out
|
||||
|
||||
def test_cli_version_option(self, capsys):
|
||||
"""CLI should display version with --version option."""
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main(["--version"])
|
||||
|
||||
assert exc_info.value.code == 0
|
||||
captured = capsys.readouterr()
|
||||
assert "0.1.0" in captured.out
|
||||
|
||||
def test_cli_invalid_log_level(self, capsys):
|
||||
"""CLI should reject invalid log level."""
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main(["--log-level", "INVALID"])
|
||||
|
||||
assert exc_info.value.code != 0
|
||||
captured = capsys.readouterr()
|
||||
assert "invalid choice" in captured.err
|
||||
Reference in New Issue
Block a user