# -*- coding: utf-8 -*- """ Reload ODS tasks by fixed time windows with optional sleep between windows. """ from __future__ import annotations import argparse import logging import subprocess import sys import time as time_mod from datetime import datetime, timedelta from pathlib import Path from zoneinfo import ZoneInfo from dateutil import parser as dtparser PROJECT_ROOT = Path(__file__).resolve().parents[1] if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) from config.settings import AppConfig from utils.logging_utils import build_log_path, configure_logging MIN_RELOAD_WINDOW_DAYS = 30 def _parse_dt(value: str, tz: ZoneInfo, *, is_end: bool) -> datetime: raw = (value or "").strip() if not raw: raise ValueError("empty datetime") has_time = any(ch in raw for ch in (":", "T")) dt = dtparser.parse(raw) if dt.tzinfo is None: dt = dt.replace(tzinfo=tz) else: dt = dt.astimezone(tz) if not has_time: dt = dt.replace(hour=23 if is_end else 0, minute=59 if is_end else 0, second=59 if is_end else 0, microsecond=0) return dt def _iter_windows(start: datetime, end: datetime, window_size: timedelta): if window_size.total_seconds() <= 0: raise ValueError("window_size must be > 0") cur = start while cur < end: nxt = min(cur + window_size, end) yield cur, nxt cur = nxt def _run_task_window( task_code: str, window_start: datetime, window_end: datetime, api_page_size: int, api_timeout: int, logger: logging.Logger, ) -> None: cmd = [ sys.executable, "-m", "cli.main", "--pipeline-flow", "FULL", "--tasks", task_code, "--window-start", window_start.strftime("%Y-%m-%d %H:%M:%S"), "--window-end", window_end.strftime("%Y-%m-%d %H:%M:%S"), "--force-window-override", ] if api_page_size > 0: cmd += ["--api-page-size", str(api_page_size)] if api_timeout > 0: cmd += ["--api-timeout", str(api_timeout)] logger.info( "RUN_TASK task=%s window_start=%s window_end=%s", task_code, window_start.isoformat(), window_end.isoformat(), ) logger.debug("CMD %s", " ".join(cmd)) subprocess.run(cmd, check=True, cwd=str(PROJECT_ROOT)) def main() -> int: ap = argparse.ArgumentParser(description="Reload ODS tasks by window slices.") ap.add_argument("--tasks", required=True, help="comma-separated ODS task codes") ap.add_argument("--start", required=True, help="start datetime, e.g. 2025-07-01") ap.add_argument("--end", default="", help="end datetime (default: now)") ap.add_argument("--window-days", type=int, default=1, help="days per window (default: 1)") ap.add_argument("--window-hours", type=int, default=0, help="hours per window (default: 0)") ap.add_argument("--sleep-seconds", type=float, default=0, help="sleep seconds after each window") ap.add_argument("--api-page-size", type=int, default=200, help="API page size override") ap.add_argument("--api-timeout", type=int, default=20, help="API timeout seconds override") ap.add_argument("--log-file", default="", help="log file path (default: logs/reload_ods_windowed_YYYYMMDD_HHMMSS.log)") ap.add_argument("--log-dir", default="", help="log directory (default: logs)") ap.add_argument("--log-level", default="INFO", help="log level (default: INFO)") ap.add_argument("--no-log-console", action="store_true", help="disable console logging") args = ap.parse_args() log_dir = Path(args.log_dir) if args.log_dir else (PROJECT_ROOT / "logs") log_file = Path(args.log_file) if args.log_file else build_log_path(log_dir, "reload_ods_windowed") log_console = not args.no_log_console with configure_logging( "reload_ods_windowed", log_file, level=args.log_level, console=log_console, tee_std=True, ) as logger: cfg = AppConfig.load({}) tz = ZoneInfo(cfg.get("app.timezone", "Asia/Taipei")) start = _parse_dt(args.start, tz, is_end=False) end = datetime.now(tz) if not args.end else _parse_dt(args.end, tz, is_end=True) window_days = int(args.window_days) window_hours = int(args.window_hours) min_hours = MIN_RELOAD_WINDOW_DAYS * 24 if window_hours > 0: if window_hours < min_hours: logger.warning( "window_hours=%s too small; adjust to %s", window_hours, min_hours, ) window_hours = min_hours elif window_days < MIN_RELOAD_WINDOW_DAYS: logger.warning( "window_days=%s too small; adjust to %s", window_days, MIN_RELOAD_WINDOW_DAYS, ) window_days = MIN_RELOAD_WINDOW_DAYS window_size = timedelta(hours=window_hours) if window_hours > 0 else timedelta(days=window_days) task_codes = [t.strip().upper() for t in args.tasks.split(",") if t.strip()] if not task_codes: raise SystemExit("no tasks specified") logger.info( "START range=%s~%s window_days=%s window_hours=%s sleep=%.2f", start.isoformat(), end.isoformat(), window_days, window_hours, args.sleep_seconds, ) for task_code in task_codes: logger.info("TASK_START task=%s", task_code) for window_start, window_end in _iter_windows(start, end, window_size): start_ts = time_mod.monotonic() _run_task_window( task_code=task_code, window_start=window_start, window_end=window_end, api_page_size=args.api_page_size, api_timeout=args.api_timeout, logger=logger, ) elapsed = time_mod.monotonic() - start_ts logger.info( "WINDOW_DONE task=%s window_start=%s window_end=%s elapsed=%.2fs", task_code, window_start.isoformat(), window_end.isoformat(), elapsed, ) if args.sleep_seconds > 0: logger.debug("SLEEP seconds=%.2f", args.sleep_seconds) time_mod.sleep(args.sleep_seconds) logger.info("TASK_DONE task=%s", task_code) return 0 if __name__ == "__main__": raise SystemExit(main())