"""Install placeholder modules for collaborators that other agents have not written yet. Only modules whose *file does not exist* are stubbed, so once the real modules land these helpers are no-ops and the orchestration tests exercise the real imports (still monkeypatched per test). Test modules import this helper first (isort puts it before the first-party ``us2kr`` block). """ from __future__ import annotations import importlib import importlib.util import sys import types from datetime import date, datetime, timedelta from zoneinfo import ZoneInfo _STUBS: dict[str, list[str]] = { "us2kr.market_time": ["MarketClock"], "us2kr.collectors.prices": ["fetch_quotes"], "us2kr.collectors.news": ["fetch_news"], "us2kr.collectors.events": ["fetch_events", "fetch_earnings"], "us2kr.store": ["Store"], "us2kr.summarize": ["build_provider", "summarize_payload"], "us2kr.render": ["render_markdown", "render_telegram_chunks"], "us2kr.deliver": ["build_deliverers"], } def _placeholder(name: str): def _stub(*_args, **_kwargs): raise RuntimeError(f"{name} is a test stub - monkeypatch it in the test") _stub.__name__ = name return _stub def install_missing_stubs() -> list[str]: """Return the list of module names that were stubbed (empty when everything exists).""" stubbed: list[str] = [] for modname, names in _STUBS.items(): if modname in sys.modules and not isinstance(sys.modules[modname], _StubModule): module = sys.modules[modname] elif importlib.util.find_spec(modname) is None: module = _StubModule(modname) sys.modules[modname] = module stubbed.append(modname) else: module = importlib.import_module(modname) for name in names: if not hasattr(module, name): # package exists but the attribute is not written yet setattr(module, name, _placeholder(name)) return stubbed class _StubModule(types.ModuleType): pass # Importing this helper (before any ``us2kr.pipeline/daemon/cli`` import) installs the stubs. STUBBED_MODULES = install_missing_stubs() # --------------------------------------------------------------------------- shared fakes ET = ZoneInfo("America/New_York") KST = ZoneInfo("Asia/Seoul") class FakeClock: """Deterministic stand-in for us2kr.market_time.MarketClock (weekdays are sessions).""" def __init__( self, *, run_offset_minutes: int = 75, krx_open: bool = True, latest: date = date(2026, 8, 25), catchup: date | None = None, next_run: tuple[date, datetime] | None = None, ) -> None: self.run_offset_minutes = run_offset_minutes self.krx_open = krx_open self.latest = latest self.catchup = catchup self.next_run = next_run self.calls: list[tuple[str, object]] = [] def is_us_session(self, d: date) -> bool: return d.weekday() < 5 def is_krx_session(self, d: date) -> bool: return self.krx_open and d.weekday() < 5 def us_session_close(self, session: date) -> datetime: if not self.is_us_session(session): raise ValueError(f"{session} is not a US session") return datetime(session.year, session.month, session.day, 16, 0, tzinfo=ET) def previous_us_session(self, before: date) -> date: d = before - timedelta(days=1) while not self.is_us_session(d): d -= timedelta(days=1) return d def next_us_session(self, after: date) -> date: d = after + timedelta(days=1) while not self.is_us_session(d): d += timedelta(days=1) return d def next_krx_session(self, on_or_after: date) -> date: d = on_or_after for _ in range(30): if self.is_krx_session(d): return d d += timedelta(days=1) return on_or_after + timedelta(days=30) # krx_open=False: KRX "never" reopens -> briefing superseded def latest_completed_session(self, now: datetime) -> date: self.calls.append(("latest_completed_session", now)) return self.latest def briefing_time_for(self, session: date) -> datetime: return (self.us_session_close(session) + timedelta(minutes=self.run_offset_minutes)).astimezone(KST) def krx_date_for(self, session: date) -> date: return self.briefing_time_for(session).date() def next_briefing(self, now: datetime) -> tuple[date, datetime]: self.calls.append(("next_briefing", now)) if self.next_run is not None: return self.next_run d = now.astimezone(ET).date() while not self.is_us_session(d) or self.briefing_time_for(d) <= now: d += timedelta(days=1) return d, self.briefing_time_for(d) def catchup_candidate(self, now: datetime, window_hours: int) -> date | None: self.calls.append(("catchup_candidate", (now, window_hours))) return self.catchup class FakeStore: """In-memory stand-in for us2kr.store.Store.""" def __init__(self, *, notes: list[str] | None = None, fail_history: bool = False) -> None: self.quotes: dict[date, list] = {} self.reports: dict[tuple[date, str], int] = {} self.saved_results: list = [] self.sent: list[tuple[int, str, str]] = [] self.raw: dict[tuple[date, str], str] = {} self.notes = notes or [] self.fail_history = fail_history self.closed = False self._next_id = 1 # weekly-recap sources: dict rows mimicking sqlite3.Row's __getitem__ access self.quote_rows: list[dict] = [] self.report_rows: list[dict] = [] def save_quotes(self, session_date: date, quotes: list) -> None: self.quotes[session_date] = list(quotes) def history_notes(self, session_date: date, quotes: list) -> list[str]: if self.fail_history: raise RuntimeError("db locked") return list(self.notes) def has_report(self, krx_date: date, kind: str = "briefing") -> bool: return (krx_date, kind) in self.reports def save_report(self, result, kind: str = "briefing") -> int: report_id = self._next_id self._next_id += 1 self.reports[(result.payload.krx_date, kind)] = report_id self.saved_results.append(result) return report_id def mark_sent(self, report_id: int, channel: str, receipt: str) -> None: self.sent.append((report_id, channel, receipt)) def save_raw(self, session_date: date, name: str, payload_json: str) -> None: self.raw[(session_date, name)] = payload_json def load_quotes_between(self, start: date, end: date) -> list[dict]: rows = [r for r in self.quote_rows if start.isoformat() <= r["session_date"] <= end.isoformat()] return sorted(rows, key=lambda r: (r["symbol"], r["session_date"])) def reports_between(self, start: date, end: date, kind: str = "briefing") -> list[dict]: rows = [r for r in self.report_rows if start.isoformat() <= r["krx_date"] <= end.isoformat()] return sorted(rows, key=lambda r: r["krx_date"]) def close(self) -> None: self.closed = True class FakeDeliverer: def __init__(self, name: str = "fake", *, fail: bool = False) -> None: self.name = name self.fail = fail self.delivered: list = [] self.errors: list[tuple[str, str]] = [] def deliver(self, result) -> str: if self.fail: raise RuntimeError(f"{self.name} is down") self.delivered.append(result) return f"{self.name}-receipt-{len(self.delivered)}" def notify_error(self, subject: str, detail: str) -> None: self.errors.append((subject, detail))