From 5cf6382e425028b572ceee493537380b5938f6b1 Mon Sep 17 00:00:00 2001 From: Patrick Selamy Date: Sun, 4 Jan 2026 20:44:17 -0500 Subject: [PATCH] feat: add CLI entry point with argparse (#55) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the main CLI entry point for the Polymarket Insider Tracker. Features: - argparse-based CLI with version, config-check, dry-run, log-level flags - Structured logging configuration with multiple formatters - Configuration validation with friendly error messages - Startup banner display - Config check mode for validation without running - Integration with Pipeline orchestrator Tests: 20 new tests covering parser, logging, banner, config validation, and integration scenarios. Closes #55 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/polymarket_insider_tracker/__main__.py | 283 +++++++++++++++++++++ tests/test_main.py | 214 ++++++++++++++++ 2 files changed, 497 insertions(+) create mode 100644 src/polymarket_insider_tracker/__main__.py create mode 100644 tests/test_main.py diff --git a/src/polymarket_insider_tracker/__main__.py b/src/polymarket_insider_tracker/__main__.py new file mode 100644 index 0000000..bf1312d --- /dev/null +++ b/src/polymarket_insider_tracker/__main__.py @@ -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() diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..715829c --- /dev/null +++ b/tests/test_main.py @@ -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