Files
US2KR/us2kr/cli.py
T
2026-08-31 14:33:53 +09:00

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())