|
@@ -2,9 +2,12 @@
|
|
|
|
|
|
|
|
ServiceClientBase owns the cross-client invariants — the stale-credential
|
|
ServiceClientBase owns the cross-client invariants — the stale-credential
|
|
|
fast-fail, error classification (a timeout is never an auth error), the
|
|
fast-fail, error classification (a timeout is never an auth error), the
|
|
|
-attempt → refresh → retry-once request skeleton, and single-flight refresh
|
|
|
|
|
-orchestration. Platform specifics (headers, payloads, parsing, endpoints)
|
|
|
|
|
-stay flat in each client module.
|
|
|
|
|
|
|
+attempt → refresh → retry-once request skeleton, single-flight refresh
|
|
|
|
|
+orchestration, and the get_calendar skeleton (per-date DayInfo cache derived
|
|
|
|
|
+from the client's MonthCache). Platform specifics (headers, payloads, parsing,
|
|
|
|
|
+endpoints) stay flat in each client module; platform-specific per-day data
|
|
|
|
|
+(e.g. Booking's rooms_to_sell) never crosses into the base — the only thing a
|
|
|
|
|
+client hands up is dict[str, DayInfo] via _month_day_infos.
|
|
|
|
|
|
|
|
Request state must be built inside the attempt closure, not captured outside
|
|
Request state must be built inside the attempt closure, not captured outside
|
|
|
it, so a retry after _ensure_refreshed() uses the reloaded session (e.g.
|
|
it, so a retry after _ensure_refreshed() uses the reloaded session (e.g.
|
|
@@ -19,8 +22,10 @@ from datetime import date
|
|
|
import httpx
|
|
import httpx
|
|
|
|
|
|
|
|
from room_cal._utils import read_env
|
|
from room_cal._utils import read_env
|
|
|
|
|
+from ._month_cache import MonthCache
|
|
|
|
|
+from ._utils import months_in_range
|
|
|
from .credentials import _env_path, clear_stale, is_stale, mark_stale
|
|
from .credentials import _env_path, clear_stale, is_stale, mark_stale
|
|
|
-from .types import ServiceAuthError
|
|
|
|
|
|
|
+from .types import DayInfo, ServiceAuthError
|
|
|
|
|
|
|
|
_log = logging.getLogger(__name__)
|
|
_log = logging.getLogger(__name__)
|
|
|
|
|
|
|
@@ -32,11 +37,14 @@ class ServiceClientBase:
|
|
|
service_key: str # registry key; also the env-file stem (<key>.env) and stale-flag name
|
|
service_key: str # registry key; also the env-file stem (<key>.env) and stale-flag name
|
|
|
service_name: str # human-readable display name
|
|
service_name: str # human-readable display name
|
|
|
auth_error_cls: type[ServiceAuthError] = ServiceAuthError
|
|
auth_error_cls: type[ServiceAuthError] = ServiceAuthError
|
|
|
|
|
+ _months: MonthCache | None = None # created by calendar-backed clients in __init__
|
|
|
|
|
|
|
|
def __init__(self) -> None:
|
|
def __init__(self) -> None:
|
|
|
self._refresh_lock = asyncio.Lock()
|
|
self._refresh_lock = asyncio.Lock()
|
|
|
self._refresh_task: asyncio.Task | None = None
|
|
self._refresh_task: asyncio.Task | None = None
|
|
|
self._refresh_is_full = False
|
|
self._refresh_is_full = False
|
|
|
|
|
+ self._cache: dict[str, DayInfo] = {} # per-date view derived from _months
|
|
|
|
|
+ self._parsed_months: set[tuple[int, int]] = set()
|
|
|
|
|
|
|
|
# ── credentials and staleness ──────────────────────────────────────────────
|
|
# ── credentials and staleness ──────────────────────────────────────────────
|
|
|
|
|
|
|
@@ -68,7 +76,43 @@ class ServiceClientBase:
|
|
|
self.clear_cache()
|
|
self.clear_cache()
|
|
|
|
|
|
|
|
def clear_cache(self) -> None:
|
|
def clear_cache(self) -> None:
|
|
|
- """Clear data caches; overridden by clients that cache."""
|
|
|
|
|
|
|
+ """Clear data caches; clients with extra derived state override and call super()."""
|
|
|
|
|
+ self._cache.clear()
|
|
|
|
|
+ self._parsed_months.clear()
|
|
|
|
|
+ if self._months is not None:
|
|
|
|
|
+ self._months.clear()
|
|
|
|
|
+
|
|
|
|
|
+ # ── calendar skeleton ──────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ def _month_day_infos(self, payload) -> dict[str, DayInfo]:
|
|
|
|
|
+ """Parse one MonthCache payload into per-date DayInfo; implemented by each client.
|
|
|
|
|
+
|
|
|
|
|
+ This is the only calendar data a client hands the base — platform-specific
|
|
|
|
|
+ per-day extras (e.g. Booking's rooms_to_sell) stay in the client's own
|
|
|
|
|
+ side tables, populated during its month fetch/parse.
|
|
|
|
|
+ """
|
|
|
|
|
+ raise NotImplementedError
|
|
|
|
|
+
|
|
|
|
|
+ async def get_calendar(self, date_from: str, date_to: str) -> dict[str, DayInfo]:
|
|
|
|
|
+ self._check_fresh()
|
|
|
|
|
+ month_list = months_in_range(date_from, date_to)
|
|
|
|
|
+ payloads = await asyncio.gather(*[self._months.get(m, y) for m, y in month_list])
|
|
|
|
|
+ for key, payload in zip(month_list, payloads):
|
|
|
|
|
+ if key not in self._parsed_months:
|
|
|
|
|
+ self._cache.update(self._month_day_infos(payload))
|
|
|
|
|
+ self._parsed_months.add(key)
|
|
|
|
|
+ return {d: info for d, info in self._cache.items() if date_from <= d <= date_to}
|
|
|
|
|
+
|
|
|
|
|
+ def _evict_derived(self, key: tuple[int, int]) -> None:
|
|
|
|
|
+ """MonthCache on_evict hook: drop the month's per-date entries.
|
|
|
|
|
+
|
|
|
|
|
+ Clients with month-keyed side tables override and call super().
|
|
|
|
|
+ """
|
|
|
|
|
+ month, year = key
|
|
|
|
|
+ self._parsed_months.discard(key)
|
|
|
|
|
+ prefix = f'{year}-{month:02d}'
|
|
|
|
|
+ for d in [k for k in self._cache if k.startswith(prefix)]:
|
|
|
|
|
+ del self._cache[d]
|
|
|
|
|
|
|
|
# ── request skeleton ───────────────────────────────────────────────────────
|
|
# ── request skeleton ───────────────────────────────────────────────────────
|
|
|
|
|
|
|
@@ -178,4 +222,4 @@ class ServiceClientBase:
|
|
|
async def probe(self) -> None:
|
|
async def probe(self) -> None:
|
|
|
"""Liveness check for the health monitor; override if a calendar fetch is not a reliable signal."""
|
|
"""Liveness check for the health monitor; override if a calendar fetch is not a reliable signal."""
|
|
|
today = date.today().isoformat()
|
|
today = date.today().isoformat()
|
|
|
- await self.get_calendar(today, today) # type: ignore[attr-defined]
|
|
|
|
|
|
|
+ await self.get_calendar(today, today)
|