177 lines
6.6 KiB
Python
177 lines
6.6 KiB
Python
"""Command-line entry point: ``us2kr run-once | daemon | next-run | test-notify | show-config``."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import logging
|
|
import re
|
|
import sys
|
|
from datetime import date, datetime
|
|
from pathlib import Path
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from us2kr import __version__
|
|
from us2kr.config import ET, KST, Settings, get_settings
|
|
from us2kr.daemon import run_daemon
|
|
from us2kr.deliver import build_deliverers
|
|
from us2kr.logging_setup import setup_logging
|
|
from us2kr.market_time import MarketClock
|
|
from us2kr.pipeline import SKIP_ALREADY_SENT, SKIP_KRX_CLOSED, delivery_failed, run_briefing
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
|
EXIT_OK = 0
|
|
EXIT_ERROR = 1
|
|
EXIT_SKIPPED = 2
|
|
SECRET_FIELD_RE = re.compile(r"(api_key|_key|_token|secret|password)$")
|
|
_PATH_FIELDS = ("data_dir", "reports_dir", "log_dir")
|
|
|
|
|
|
def _resolve_paths(settings: Settings, root: Path = PROJECT_ROOT) -> Settings:
|
|
"""Make relative path settings absolute against the project root (launchd starts with cwd=/)."""
|
|
update = {}
|
|
for name in _PATH_FIELDS:
|
|
value = Path(getattr(settings, name))
|
|
if not value.is_absolute():
|
|
update[name] = root / value
|
|
return settings.model_copy(update=update) if update else settings
|
|
|
|
|
|
def _load_settings() -> Settings:
|
|
"""Load settings; fall back to the project-root .env when none exists in the current directory."""
|
|
root_env = PROJECT_ROOT / ".env"
|
|
if not Path(".env").is_file() and root_env.is_file():
|
|
settings = Settings(_env_file=root_env) # type: ignore[call-arg]
|
|
else:
|
|
settings = get_settings()
|
|
return _resolve_paths(settings, PROJECT_ROOT)
|
|
|
|
|
|
def mask_secret(value: object) -> str:
|
|
if value is None or value == "":
|
|
return "(unset)"
|
|
text = str(value)
|
|
return f"{text[:4]}***" if len(text) > 8 else "***"
|
|
|
|
|
|
def _parse_date(text: str) -> date:
|
|
try:
|
|
return date.fromisoformat(text)
|
|
except ValueError as exc:
|
|
raise argparse.ArgumentTypeError(f"invalid date {text!r}, expected YYYY-MM-DD") from exc
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(prog="us2kr", description="US market close -> Korea open briefing bot")
|
|
parser.add_argument("--version", action="version", version=f"us2kr {__version__}")
|
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
|
|
once = sub.add_parser("run-once", help="build (and deliver) one briefing")
|
|
once.add_argument(
|
|
"--date",
|
|
type=_parse_date,
|
|
default=None,
|
|
help="US session date YYYY-MM-DD (must already be closed unless --dry-run)",
|
|
)
|
|
once.add_argument("--dry-run", action="store_true", help="print markdown, do not save or deliver")
|
|
once.add_argument("--force", action="store_true", help="re-run (resend) even if already sent")
|
|
once.add_argument("--no-llm", action="store_true", help="skip the LLM (tables only)")
|
|
|
|
sub.add_parser("daemon", help="run the resident scheduler")
|
|
sub.add_parser("next-run", help="show the next scheduled briefing")
|
|
|
|
notify = sub.add_parser("test-notify", help="send a test message through every delivery channel")
|
|
notify.add_argument("--message", default="US2KR test notification", help="text to send")
|
|
|
|
sub.add_parser("show-config", help="print effective settings (secrets masked)")
|
|
return parser
|
|
|
|
|
|
# ---------------------------------------------------------------------------- commands
|
|
def cmd_run_once(args: argparse.Namespace, settings: Settings) -> int:
|
|
result = run_briefing(args.date, settings, dry_run=args.dry_run, force=args.force, no_llm=args.no_llm)
|
|
if result.error in (SKIP_ALREADY_SENT, SKIP_KRX_CLOSED):
|
|
print(f"skipped: {result.error} (KRX date {result.payload.krx_date})", file=sys.stderr)
|
|
return EXIT_SKIPPED
|
|
if result.degraded:
|
|
log.warning("briefing produced in degraded mode: %s", result.error or "no LLM")
|
|
if delivery_failed(result):
|
|
print(f"error: {result.error} (KRX date {result.payload.krx_date})", file=sys.stderr)
|
|
return EXIT_ERROR
|
|
return EXIT_OK
|
|
|
|
|
|
def cmd_daemon(_args: argparse.Namespace, settings: Settings) -> int:
|
|
run_daemon(settings)
|
|
return EXIT_OK
|
|
|
|
|
|
def cmd_next_run(_args: argparse.Namespace, settings: Settings) -> int:
|
|
clock = MarketClock(settings.run_offset_minutes, extra_krx_holidays=settings.krx_extra_holidays)
|
|
now = datetime.now(ZoneInfo(KST))
|
|
session, run_at = clock.next_briefing(now)
|
|
close_et = clock.us_session_close(session)
|
|
krx_date = clock.krx_date_for(session)
|
|
krx_open = clock.is_krx_session(krx_date)
|
|
print(f"US session : {session}")
|
|
print(f"US close (ET) : {close_et.isoformat(timespec='minutes')} [{ET}]")
|
|
print(
|
|
f"briefing at KST : {run_at.isoformat(timespec='minutes')} (offset {settings.run_offset_minutes} min)"
|
|
)
|
|
print(f"KRX date : {krx_date} ({'open' if krx_open else 'CLOSED'})")
|
|
return EXIT_OK
|
|
|
|
|
|
def cmd_test_notify(args: argparse.Namespace, settings: Settings) -> int:
|
|
deliverers = build_deliverers(settings)
|
|
if not deliverers:
|
|
print("no delivery channels configured", file=sys.stderr)
|
|
return EXIT_ERROR
|
|
for deliverer in deliverers:
|
|
deliverer.notify_error("US2KR 테스트 알림", args.message)
|
|
print(f"sent test notification via {deliverer.name}")
|
|
return EXIT_OK
|
|
|
|
|
|
def cmd_show_config(_args: argparse.Namespace, settings: Settings) -> int:
|
|
for name, value in settings.model_dump().items():
|
|
shown = mask_secret(value) if SECRET_FIELD_RE.search(name) else value
|
|
print(f"{name} = {shown}")
|
|
print(f"db_path = {settings.db_path}")
|
|
print(f"telegram_enabled = {settings.telegram_enabled}")
|
|
print(f"project_root = {PROJECT_ROOT}")
|
|
return EXIT_OK
|
|
|
|
|
|
COMMANDS = {
|
|
"run-once": cmd_run_once,
|
|
"daemon": cmd_daemon,
|
|
"next-run": cmd_next_run,
|
|
"test-notify": cmd_test_notify,
|
|
"show-config": cmd_show_config,
|
|
}
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = build_parser()
|
|
args = parser.parse_args(argv)
|
|
try:
|
|
settings = _load_settings()
|
|
except Exception as exc: # noqa: BLE001 - bad .env must produce a readable message
|
|
print(f"error: could not load settings: {exc}", file=sys.stderr)
|
|
return EXIT_ERROR
|
|
setup_logging(settings)
|
|
try:
|
|
return COMMANDS[args.command](args, settings)
|
|
except KeyboardInterrupt:
|
|
return EXIT_ERROR
|
|
except Exception as exc: # top-level guard: log and exit non-zero
|
|
log.exception("command %s failed", args.command)
|
|
print(f"error: {exc}", file=sys.stderr)
|
|
return EXIT_ERROR
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|