瀏覽代碼

service_api: base owns the get_calendar skeleton and per-date DayInfo cache

The no-platform-data-in-shared-types rule applies to payloads, not machinery:
the per-date cache dict, month-prefix eviction, and get_calendar template are
pure mechanism and move into ServiceClientBase. Clients implement one hook,
_month_day_infos(payload) -> dict[str, DayInfo] — the hook signature is the
boundary, so platform extras (Booking's _rts, Airbnb's raw reservationData)
stay in client-internal state with no slot to leak through.

- Base: _cache + _parsed_months, get_calendar template, _evict_derived as the
  standard MonthCache on_evict hook, real clear_cache default (override +
  super() for extras). MonthCache stays client-constructed — integrity/retry
  strategies (suspect predicates, Airbnb's revalidate/on_fetch guard) are
  per-platform.
- Airbnb: get_calendar, _evict_derived, and _fetched_months deleted;
  _month_day_infos is the old inline parse loop.
- Booking: get_calendar deleted; _month_day_infos is identity (months are
  parsed at fetch time); _evict_derived keeps only the _rts cleanup.
- Conformance suite: shared tests that get_calendar parses once, serves
  repeats from cache with no network calls, range-filters, and reparses
  after eviction.
ZiB 3 月之前
父節點
當前提交
019019cc62

+ 10 - 31
room_cal/service_api/_airbnb.py

@@ -131,8 +131,6 @@ class _AirbnbClient(ServiceClientBase):
     self._listing_id = listing_id
     self._listing_id = listing_id
     self._global_id = _encode_listing_id(listing_id)
     self._global_id = _encode_listing_id(listing_id)
     self._tz = tz
     self._tz = tz
-    self._cache: dict[str, DayInfo] = {}
-    self._fetched_months: set[tuple[int, int]] = set()
     # Tracks months where we've seen at least one booked day; survives clear_cache() so we can
     # Tracks months where we've seen at least one booked day; survives clear_cache() so we can
     # detect when a freshly-fetched month drops from N booked days to 0 (stale replica signal).
     # detect when a freshly-fetched month drops from N booked days to 0 (stale replica signal).
     self._had_bookings: set[tuple[int, int]] = set()
     self._had_bookings: set[tuple[int, int]] = set()
@@ -159,9 +157,7 @@ class _AirbnbClient(ServiceClientBase):
     return cls(cookies=env['AIRBNB_COOKIES'], listing_id=listing_id)
     return cls(cookies=env['AIRBNB_COOKIES'], listing_id=listing_id)
 
 
   def clear_cache(self) -> None:
   def clear_cache(self) -> None:
-    self._cache.clear()
-    self._fetched_months.clear()
-    self._months.clear()
+    super().clear_cache()
     self._details.clear()
     self._details.clear()
     self._booked_nets.clear()
     self._booked_nets.clear()
 
 
@@ -286,33 +282,16 @@ class _AirbnbClient(ServiceClientBase):
       # Forced refetch confirmed no bookings — stop guarding this month
       # Forced refetch confirmed no bookings — stop guarding this month
       self._had_bookings.discard(key)
       self._had_bookings.discard(key)
 
 
-  def _evict_derived(self, key: tuple[int, int]) -> None:
-    month, year = key
-    self._fetched_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]
-
-  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)
-    all_days = await asyncio.gather(*[self._months.get(m, y) for m, y in month_list])
-    for (month, year), days in zip(month_list, all_days):
-      if (month, year) not in self._fetched_months:
-        for day in days:
-          d = day.get('day', '')
-          if d:
-            self._cache[d] = DayInfo(
-              date=d,
-              price=day.get('priceData', {}).get('price'),
-              available=day.get('available', False),
-              booked=day.get('reservationData') is not None,
-            )
-        self._fetched_months.add((month, year))
+  def _month_day_infos(self, days: list[dict]) -> dict[str, DayInfo]:
     return {
     return {
-      d: info
-      for d, info in self._cache.items()
-      if date_from <= d <= date_to
+      d: DayInfo(
+        date=d,
+        price=day.get('priceData', {}).get('price'),
+        available=day.get('available', False),
+        booked=day.get('reservationData') is not None,
+      )
+      for day in days
+      if (d := day.get('day', ''))
     }
     }
 
 
   async def get_reservations(self, date_from: str, date_to: str) -> list[Reservation]:
   async def get_reservations(self, date_from: str, date_to: str) -> list[Reservation]:

+ 50 - 6
room_cal/service_api/_base.py

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

+ 8 - 14
room_cal/service_api/_booking.py

@@ -14,7 +14,7 @@ from room_cal._utils import get_config
 from ._base import ServiceClientBase
 from ._base import ServiceClientBase
 from ._month_cache import MonthCache
 from ._month_cache import MonthCache
 from ._registry import register_service
 from ._registry import register_service
-from ._utils import month_bounds, months_in_range
+from ._utils import month_bounds
 from .credentials import set_credentials
 from .credentials import set_credentials
 from .types import DayInfo, ServiceAuthError, Reservation
 from .types import DayInfo, ServiceAuthError, Reservation
 
 
@@ -184,7 +184,6 @@ class _BookingClient(ServiceClientBase):
     self._rate_id = rate_id
     self._rate_id = rate_id
     self._csrf_token = csrf_token
     self._csrf_token = csrf_token
     self._lang = lang
     self._lang = lang
-    self._cache: dict[str, DayInfo] = {}
     self._rts: dict[str, int] = {}  # date -> rooms_to_sell; internal, not exposed on DayInfo
     self._rts: dict[str, int] = {}  # date -> rooms_to_sell; internal, not exposed on DayInfo
     self._months: MonthCache[dict[str, DayInfo]] = MonthCache(
     self._months: MonthCache[dict[str, DayInfo]] = MonthCache(
       lambda m, y: self._fetch_month_calendar(m, y),
       lambda m, y: self._fetch_month_calendar(m, y),
@@ -214,9 +213,8 @@ class _BookingClient(ServiceClientBase):
     )
     )
 
 
   def clear_cache(self) -> None:
   def clear_cache(self) -> None:
-    self._cache.clear()
+    super().clear_cache()
     self._rts.clear()
     self._rts.clear()
-    self._months.clear()
 
 
   def _apply_env(self, env: dict[str, str]) -> None:
   def _apply_env(self, env: dict[str, str]) -> None:
     self._cookies = env.get('BOOKING_COOKIES', self._cookies)
     self._cookies = env.get('BOOKING_COOKIES', self._cookies)
@@ -474,22 +472,18 @@ class _BookingClient(ServiceClientBase):
       return False  # past months legitimately return no data
       return False  # past months legitimately return no data
     return not result or not any(info.price is not None for info in result.values())
     return not result or not any(info.price is not None for info in result.values())
 
 
+  def _month_day_infos(self, payload: dict[str, DayInfo]) -> dict[str, DayInfo]:
+    # month payloads are already parsed DayInfo; _rts is populated at fetch
+    # time in _parse_calendar_days and never crosses into the base
+    return payload
+
   def _evict_derived(self, key: tuple[int, int]) -> None:
   def _evict_derived(self, key: tuple[int, int]) -> None:
+    super()._evict_derived(key)
     month, year = key
     month, year = key
     prefix = f'{year}-{month:02d}'
     prefix = f'{year}-{month:02d}'
-    for d in [k for k in self._cache if k.startswith(prefix)]:
-      del self._cache[d]
     for d in [k for k in self._rts if k.startswith(prefix)]:
     for d in [k for k in self._rts if k.startswith(prefix)]:
       del self._rts[d]
       del self._rts[d]
 
 
-  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)
-    all_results = await asyncio.gather(*[self._months.get(m, y) for m, y in month_list])
-    for month_result in all_results:
-      self._cache.update(month_result)
-    return {d: info for d, info in self._cache.items() if date_from <= d <= date_to}
-
   async def get_reservations(self, date_from: str, date_to: str) -> list[Reservation]:
   async def get_reservations(self, date_from: str, date_to: str) -> list[Reservation]:
     self._check_fresh()
     self._check_fresh()
     async def _attempt() -> httpx.Response:
     async def _attempt() -> httpx.Response:

+ 4 - 4
tests/test_reservation_guards.py

@@ -178,8 +178,8 @@ class TestHadBookings:
     await client._months.get(7, 2026)
     await client._months.get(7, 2026)
     assert key not in client._had_bookings, "confirmed empty — guard should be lifted"
     assert key not in client._had_bookings, "confirmed empty — guard should be lifted"
 
 
-  async def test_refetch_invalidates_fetched_months_and_cache(self):
-    """Forcing a refetch must also clear _fetched_months and _cache for that month."""
+  async def test_refetch_invalidates_parsed_months_and_cache(self):
+    """Forcing a refetch must also clear _parsed_months and _cache for that month."""
     from room_cal.service_api.types import DayInfo
     from room_cal.service_api.types import DayInfo
     _dummy = DayInfo(date='2026-07-10', price=100, available=True, booked=False)
     _dummy = DayInfo(date='2026-07-10', price=100, available=True, booked=False)
 
 
@@ -187,7 +187,7 @@ class TestHadBookings:
     key = (7, 2026)
     key = (7, 2026)
     client._had_bookings.add(key)
     client._had_bookings.add(key)
     self._seed(client, key, _days(booked=False))
     self._seed(client, key, _days(booked=False))
-    client._fetched_months.add(key)
+    client._parsed_months.add(key)
     client._cache['2026-07-10'] = _dummy
     client._cache['2026-07-10'] = _dummy
     client._cache['2026-08-01'] = _dummy  # different month — must survive
     client._cache['2026-08-01'] = _dummy  # different month — must survive
 
 
@@ -195,7 +195,7 @@ class TestHadBookings:
     client._fetch_month_days = _fetch
     client._fetch_month_days = _fetch
 
 
     await client._months.get(7, 2026)
     await client._months.get(7, 2026)
-    assert key not in client._fetched_months
+    assert key not in client._parsed_months
     assert '2026-07-10' not in client._cache
     assert '2026-07-10' not in client._cache
     assert '2026-08-01' in client._cache, "different month must not be evicted"
     assert '2026-08-01' in client._cache, "different month must not be evicted"
 
 

+ 33 - 0
tests/test_service_client_conformance.py

@@ -448,3 +448,36 @@ class TestAirbnbBodyAuth:
     assert result == body
     assert result == body
     assert len(captured) == 1
     assert len(captured) == 1
     assert not is_stale(adapter.name)
     assert not is_stale(adapter.name)
+
+
+# ── Base get_calendar skeleton ────────────────────────────────────────────────
+
+class TestGetCalendarSkeleton:
+  """The base owns get_calendar: parse each month payload once via the client's
+  _month_day_infos hook, serve repeats from the per-date cache, filter to range."""
+
+  async def test_populates_serves_and_filters_without_reparse(self, adapter):
+    client, captured = adapter.make_client([])
+    _seed(client._months, (7, 2026), adapter.ok_calendar_result)
+    parses = []
+    orig = client._month_day_infos
+    client._month_day_infos = lambda payload: (parses.append(1) or orig(payload))
+
+    r1 = await client.get_calendar('2026-07-01', '2026-07-31')
+    r2 = await client.get_calendar('2026-07-01', '2026-07-31')
+    assert '2026-07-10' in r1
+    assert r1['2026-07-10'].price == 100
+    assert r1 == r2
+    assert parses == [1]
+    assert captured == []  # cache-served: no network calls
+
+    assert await client.get_calendar('2026-07-11', '2026-07-31') == {}  # range filter
+
+  async def test_eviction_reparses(self, adapter):
+    client, _ = adapter.make_client([])
+    _seed(client._months, (7, 2026), adapter.ok_calendar_result)
+    await client.get_calendar('2026-07-01', '2026-07-31')
+    assert '2026-07-10' in client._cache
+    client._months.invalidate(7, 2026)
+    assert '2026-07-10' not in client._cache
+    assert (7, 2026) not in client._parsed_months