Przeglądaj źródła

Make ServiceAPI the source of truth for available services

- Add ServiceAPI.get()/set() singleton; services() and service_name() in
  _utils now delegate to it via lazy import, breaking the circular dep
- Hardcode service_name on each client class (_AirbnbClient, _BookingClient)
  instead of reading it from config
- Remove ServiceConfig.disabled — clients that fail to load are excluded
  naturally; disabled-in-config is no longer a supported mechanism
- services() now returns list[str] (loaded client keys); all callers updated
- Drop stale account_id from the /services API response (unused by frontend)
ZiB 4 miesięcy temu
rodzic
commit
4fa05c2079

+ 1 - 2
room_cal/_health.py

@@ -70,9 +70,8 @@ async def credentials_checks(api: ServiceAPI | None = None) -> dict[str, HealthR
   out: dict[str, HealthResult] = {
     key: HealthResult(ok=False, reason=f'load error: {reason}')
     for key, reason in api._disabled.items()
-    if reason != 'disabled in config'
   }
-  keys = [s.key for s in services() if s.key in api._clients]
+  keys = services()
   if keys:
     results = await asyncio.gather(*[_probe_service(k, api) for k in keys])
     out.update(zip(keys, results))

+ 7 - 8
room_cal/_utils.py

@@ -158,7 +158,6 @@ class ServiceConfig:
   account_id: str = ''
   hotel_id: str = ''
   rate_id: str = ''
-  disabled: bool = False
   fees: ServiceFees = field(default_factory=ServiceFees)
   taxes: ServiceTaxes = field(default_factory=ServiceTaxes)
 
@@ -171,7 +170,6 @@ class ServiceConfig:
       account_id=str(d.get('account_id', '')),
       hotel_id=str(d.get('hotel_id', '')),
       rate_id=str(d.get('rate_id', '')),
-      disabled=bool(d.get('disabled', False)),
       fees=ServiceFees.from_dict(d.get('fees', {})),
       taxes=ServiceTaxes.from_dict(d.get('taxes', {})),
     )
@@ -461,15 +459,16 @@ def local_today() -> date:
   return datetime.now(tz).date()
 
 
-def services() -> list[ServiceConfig]:
-  """Return list of ServiceConfig for all configured services, excluding disabled ones."""
-  return [s for s in get_config().services.values() if not s.disabled]
+def services() -> list[str]:
+  """Return the keys of all successfully loaded services."""
+  from room_cal.service_api.client import ServiceAPI
+  return ServiceAPI.get().keys()
 
 
 def service_name(key: str) -> str:
-  """Return the display name for a service key, falling back to key.capitalize()."""
-  svc = get_config().services.get(key)
-  return svc.name if svc else key.capitalize()
+  """Return the display name for a service key."""
+  from room_cal.service_api.client import ServiceAPI
+  return ServiceAPI.get().service_name(key)
 
 
 def source_line(source: dict) -> str:

+ 1 - 1
room_cal/actions/_cli.py

@@ -168,7 +168,7 @@ async def _diff(start: date, end: date, args) -> None:
   from room_cal.actions.diff import diff
   from room_cal._utils import services
   result = await diff(start, end)
-  svc_keys = [s.key for s in services()]
+  svc_keys = services()
   any_diff = False
 
   prices = result['prices']

+ 1 - 1
room_cal/actions/diff.py

@@ -97,7 +97,7 @@ async def _diff_prices(
 
   from room_cal.pricing.price_calendar import PriceData
 
-  svc_keys = [s.key for s in _services()]
+  svc_keys = _services()
   cal_prices: dict[str, PriceData] = {}
   for e in filter_events(events, _PRICE_PREFIX, all_day=True):
     d = safe_json_loads(e.x_data)

+ 1 - 1
room_cal/actions/fetch_feed.py

@@ -37,7 +37,7 @@ async def fetch_feed(
     for d in event_dates(event, start_date, end_date):
       blocked.add(d)
 
-  all_services = sorted(s.key for s in _services())
+  all_services = sorted(_services())
   closed_cal = Closed()
 
   existing_closed = await closed_cal.list(start_date, end_date)

+ 1 - 1
room_cal/actions/fetch_prices.py

@@ -42,7 +42,7 @@ async def fetch_prices(
   if not api_prices:
     return FetchResult(data=[], changed=False)
 
-  service_keys = [service] if service else [s.key for s in _services()]
+  service_keys = [service] if service else _services()
   cal = CalPrice()
   existing_events, res_events, discount_by_date = await asyncio.gather(
     cal.list(start_date, end_date),

+ 1 - 1
room_cal/actions/fetch_reservations.py

@@ -55,7 +55,7 @@ async def fetch_reservations(
   res_cal = Reservation()
   closed = Closed()
   user_cal = UserCal()
-  all_service_keys = [s.key for s in _services()]
+  all_service_keys = _services()
 
   existing_events = await res_cal.list(start_date, end_date)
   existing_by_id: dict[str, tuple] = {}

+ 1 - 1
room_cal/actions/fetch_user_cal.py

@@ -22,7 +22,7 @@ async def fetch_user_cal(
   """
   user_cal = UserCal()
   closed_cal = Closed()
-  all_services = sorted(s.key for s in _services())
+  all_services = sorted(_services())
   source = {'domain': _USER_CAL_SOURCE, 'name': 'User =>', 'id': USER_CAL_DEFAULT}
 
   blocked: set[date] = set()

+ 1 - 1
room_cal/actions/push_prices.py

@@ -39,7 +39,7 @@ async def push_prices(
     datetime(end_date.year, end_date.month, end_date.day) + timedelta(days=1),
   )
 
-  svc_keys = [s.key for s in _services()]
+  svc_keys = _services()
   cal_prices: dict[str, PriceData] = {}
   for e in filter_events(events, _PRICE_PREFIX, all_day=True):
     d = safe_json_loads(e.x_data)

+ 2 - 2
room_cal/api/_routes/credentials.py

@@ -23,7 +23,7 @@ async def _probe(key: str, api: ServiceAPI) -> dict:
 @router.get('/credentials')
 async def get_credentials_status():
   api = ServiceAPI()
-  keys = [s.key for s in services() if s.key in api._clients]
+  keys = services()
   results = await asyncio.gather(*[_probe(k, api) for k in keys])
   return dict(zip(keys, results))
 
@@ -40,7 +40,7 @@ async def get_credential_status(service: str):
 async def post_credentials_refresh(body: dict = Body(default={})):
   """Trigger automatic credential refresh for one service (body: {service: key}) or all services."""
   service = (body or {}).get('service')
-  keys = [service] if service else [s.key for s in services()]
+  keys = [service] if service else services()
   api = ServiceAPI()
 
   async def _refresh(key: str) -> None:

+ 6 - 7
room_cal/api/_routes/services.py

@@ -1,18 +1,17 @@
 from fastapi import APIRouter
-from room_cal._utils import services as _services
+from room_cal._utils import service_name, services as _services
 from room_cal.service_api import ServiceAPI
 router = APIRouter()
 
 
 @router.get('/services')
 async def get_services():
-  api = ServiceAPI()
+  api = ServiceAPI.get()
   return [
     {
-      'key': s.key,
-      'name': s.name,
-      'supports_chat': hasattr(api._clients.get(s.key), 'get_thread'),
-      'account_id': s.account_id,
+      'key': key,
+      'name': service_name(key),
+      'supports_chat': hasattr(api._clients.get(key), 'get_thread'),
     }
-    for s in _services()
+    for key in _services()
   ]

+ 2 - 2
room_cal/api/calc_app.py

@@ -3,7 +3,7 @@ from pathlib import Path
 from fastapi import FastAPI
 from fastapi.middleware.cors import CORSMiddleware
 from fastapi.responses import HTMLResponse
-from room_cal._utils import services as _services
+from room_cal._utils import services as _services, service_name
 from room_cal.api._routes.calc import router as _calc_router
 
 _CALC_HTML = Path(__file__).parents[1] / 'vue' / 'calc.html'
@@ -16,7 +16,7 @@ app.include_router(_calc_router)
 
 @app.get('/services')
 async def get_services():
-  return [{'key': s.key, 'name': s.name} for s in _services()]
+  return [{'key': s, 'name': service_name(s)} for s in _services()]
 
 
 @app.get('/', response_class=HTMLResponse, include_in_schema=False)

+ 1 - 1
room_cal/closed/__init__.py

@@ -18,7 +18,7 @@ ClosedList = dict[date, ClosedData]
 
 
 def _build_title(closed_services: list[str]) -> str:
-  all_keys = {s.key for s in _services()}
+  all_keys = set(_services())
   if all_keys <= set(closed_services):
     return f"{_PREFIX}All"
   names = [service_name(s) for s in sorted(closed_services)]

+ 3 - 3
room_cal/mcp/__init__.py

@@ -1,7 +1,7 @@
 import asyncio
 from datetime import date
 from fastmcp import FastMCP
-from room_cal._utils import services as _services, safe_json_loads, iter_dates
+from room_cal._utils import services as _services, service_name as _service_name, safe_json_loads, iter_dates
 from room_cal._health import store_check, credentials_checks
 from room_cal.pricing import CalPrice
 from room_cal.pricing.calc import (
@@ -37,7 +37,7 @@ async def health_check() -> dict:
 
 @mcp.tool()
 async def list_services() -> list[dict]:
-  return [{'key': s.key, 'name': s.name} for s in _services()]
+  return [{'key': s, 'name': _service_name(s)} for s in _services()]
 
 
 @mcp.tool()
@@ -181,7 +181,7 @@ async def refresh_credentials(service: str | None = None) -> dict:
   """Trigger automatic credential refresh via Playwright. service: optional key (e.g. 'booking', 'airbnb'); omit to refresh all. Booking.com includes messaging 2FA and may take 2+ minutes. Returns {refreshed: [...], errors: {key: reason}}."""
   from room_cal.service_api import ServiceAPI
   from room_cal.service_api.types import ServiceAuthError
-  keys = [service] if service else [s.key for s in _services()]
+  keys = [service] if service else _services()
   api = ServiceAPI()
 
   async def _do(key: str) -> tuple[str, str | None]:

+ 1 - 1
room_cal/pricing/price_calendar.py

@@ -52,7 +52,7 @@ PriceList = dict[str, 'PriceData | None']
 class CalPrice(StoreBacked):
   def __init__(self, *, backend=None):
     super().__init__(backend=backend)
-    self._services = [s.key for s in _services()]
+    self._services = _services()
 
   async def _get_prices(self, target_date: date) -> 'PriceData | None':
     events = await self._fetch_date_range(target_date, target_date)

+ 1 - 1
room_cal/rules/closed/future_block.py

@@ -12,7 +12,7 @@ async def run(start_date: date, end_date: date, data: ClosedList) -> ClosedList:
   days_out = get_config().rules.future_block.days_out
   threshold = local_today() + timedelta(days=days_out)
   open_ = await open_dates(start_date, end_date)
-  all_keys = sorted(s.key for s in _services())
+  all_keys = sorted(_services())
   return {
     d: ClosedData(services=all_keys if d > threshold else [])
     for d in iter_dates(start_date, end_date)

+ 2 - 0
room_cal/service_api/_airbnb.py

@@ -73,6 +73,8 @@ def _trace_id() -> str:
 
 
 class _AirbnbClient:
+  service_name = 'Airbnb'
+
   def __init__(self, cookies: str, listing_id: str, tz: str = 'America/Los_Angeles'):
     self._cookies = cookies
     self._listing_id = listing_id

+ 1 - 0
room_cal/service_api/_booking.py

@@ -146,6 +146,7 @@ def _page_view_id() -> str:
 
 
 class _BookingClient:
+  service_name = 'Booking.com'
 
   def __init__(
     self,

+ 28 - 7
room_cal/service_api/client.py

@@ -8,6 +8,8 @@ from ._protocols import ChatClient, ServiceClient
 from .types import DayInfo, ServiceAuthError
 
 _log = logging.getLogger(__name__)
+_instance: 'ServiceAPI | None' = None
+
 
 class ServiceAPI:
   """Unified async client for all booking platform services.
@@ -24,25 +26,44 @@ class ServiceAPI:
     'booking': _BookingClient,
   }
 
+  @classmethod
+  def get(cls) -> 'ServiceAPI':
+    """Return the process-wide singleton, creating it on first call."""
+    global _instance
+    if _instance is None:
+      _instance = cls()
+    return _instance
+
+  @classmethod
+  def set(cls, instance: 'ServiceAPI') -> None:
+    """Replace the singleton — use in tests to install a stub."""
+    global _instance
+    _instance = instance
+
   def __init__(self, clients: dict[str, ServiceClient] | None = None) -> None:
     if clients is not None:
       self._clients: dict[str, ServiceClient] = clients
+      self._disabled: dict[str, str] = {}
       return
-    from room_cal._utils import get_config
     self._clients = {}
     self._disabled: dict[str, str] = {}
-    svc_cfg = get_config().services
     for key, cls in self._CLIENT_CLASSES.items():
-      if (cfg := svc_cfg.get(key)) and cfg.disabled:
-        _log.info('service %s disabled by config', key)
-        self._disabled[key] = 'disabled in config'
-        continue
       try:
         self._clients[key] = cls.from_env()
       except Exception as e:
-        _log.warning('service %s disabled: %s', key, e)
+        _log.warning('service %s failed to load: %s', key, e)
         self._disabled[key] = str(e)
 
+  def keys(self) -> list[str]:
+    """Return the keys of all successfully loaded services."""
+    return list(self._clients.keys())
+
+  def service_name(self, key: str) -> str:
+    """Return the human-readable display name for a service key."""
+    cls = self._CLIENT_CLASSES.get(key)
+    return getattr(cls, 'service_name', key.capitalize()) if cls else key.capitalize()
+
+
   async def refresh_credentials(self, key: str) -> None:
     """Trigger credential refresh for the named service. Raises ServiceAuthError on failure."""
     cls = self._CLIENT_CLASSES.get(key)

+ 1 - 1
room_cal/user_cal/__init__.py

@@ -22,7 +22,7 @@ class UserCal(StoreBacked, EventSource):
 
   async def find_services(self, start_date: date, end_date: date) -> list:
     """Return all service-named all-day events in the date range."""
-    display_names = {s.name for s in _services()}
+    display_names = {service_name(s) for s in _services()}
     events = await self._fetch_date_range(start_date, end_date)
     return [e for e in events if e.all_day and e.title in display_names and e.x_data]