Quellcode durchsuchen

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 vor 3 Monaten
Ursprung
Commit
019019cc62

+ 10 - 31
room_cal/service_api/_airbnb.py

@@ -131,8 +131,6 @@ class _AirbnbClient(ServiceClientBase):
     self._listing_id = listing_id
     self._global_id = _encode_listing_id(listing_id)
     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
     # detect when a freshly-fetched month drops from N booked days to 0 (stale replica signal).
     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)
 
   def clear_cache(self) -> None:
-    self._cache.clear()
-    self._fetched_months.clear()
-    self._months.clear()
+    super().clear_cache()
     self._details.clear()
     self._booked_nets.clear()
 
@@ -286,33 +282,16 @@ class _AirbnbClient(ServiceClientBase):
       # Forced refetch confirmed no bookings — stop guarding this month
       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 {
-      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]:

+ 50 - 6
room_cal/service_api/_base.py

@@ -2,9 +2,12 @@
 
 ServiceClientBase owns the cross-client invariants — the stale-credential
 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
 it, so a retry after _ensure_refreshed() uses the reloaded session (e.g.
@@ -19,8 +22,10 @@ from datetime import date
 import httpx
 
 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 .types import ServiceAuthError
+from .types import DayInfo, ServiceAuthError
 
 _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_name: str     # human-readable display name
   auth_error_cls: type[ServiceAuthError] = ServiceAuthError
+  _months: MonthCache | None = None  # created by calendar-backed clients in __init__
 
   def __init__(self) -> None:
     self._refresh_lock = asyncio.Lock()
     self._refresh_task: asyncio.Task | None = None
     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 ──────────────────────────────────────────────
 
@@ -68,7 +76,43 @@ class ServiceClientBase:
     self.clear_cache()
 
   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 ───────────────────────────────────────────────────────
 
@@ -178,4 +222,4 @@ class ServiceClientBase:
   async def probe(self) -> None:
     """Liveness check for the health monitor; override if a calendar fetch is not a reliable signal."""
     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 ._month_cache import MonthCache
 from ._registry import register_service
-from ._utils import month_bounds, months_in_range
+from ._utils import month_bounds
 from .credentials import set_credentials
 from .types import DayInfo, ServiceAuthError, Reservation
 
@@ -184,7 +184,6 @@ class _BookingClient(ServiceClientBase):
     self._rate_id = rate_id
     self._csrf_token = csrf_token
     self._lang = lang
-    self._cache: dict[str, DayInfo] = {}
     self._rts: dict[str, int] = {}  # date -> rooms_to_sell; internal, not exposed on DayInfo
     self._months: MonthCache[dict[str, DayInfo]] = MonthCache(
       lambda m, y: self._fetch_month_calendar(m, y),
@@ -214,9 +213,8 @@ class _BookingClient(ServiceClientBase):
     )
 
   def clear_cache(self) -> None:
-    self._cache.clear()
+    super().clear_cache()
     self._rts.clear()
-    self._months.clear()
 
   def _apply_env(self, env: dict[str, str]) -> None:
     self._cookies = env.get('BOOKING_COOKIES', self._cookies)
@@ -474,22 +472,18 @@ class _BookingClient(ServiceClientBase):
       return False  # past months legitimately return no data
     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:
+    super()._evict_derived(key)
     month, year = key
     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)]:
       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]:
     self._check_fresh()
     async def _attempt() -> httpx.Response:

+ 4 - 4
tests/test_reservation_guards.py

@@ -178,8 +178,8 @@ class TestHadBookings:
     await client._months.get(7, 2026)
     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
     _dummy = DayInfo(date='2026-07-10', price=100, available=True, booked=False)
 
@@ -187,7 +187,7 @@ class TestHadBookings:
     key = (7, 2026)
     client._had_bookings.add(key)
     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-08-01'] = _dummy  # different month — must survive
 
@@ -195,7 +195,7 @@ class TestHadBookings:
     client._fetch_month_days = _fetch
 
     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-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 len(captured) == 1
     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