client.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  1. import asyncio
  2. import logging
  3. from datetime import date
  4. from ._airbnb import _AirbnbClient
  5. from ._booking import _BookingClient
  6. from ._protocols import ChatClient, ServiceClient
  7. from .types import DayInfo, ServiceAuthError
  8. _log = logging.getLogger(__name__)
  9. _instance: 'ServiceAPI | None' = None
  10. class ServiceAPI:
  11. """Unified async client for all booking platform services.
  12. Credentials are read from <service>.env files in the data directory
  13. (~/.local/room_cal/ or ROOM_CAL_DATA_HOME).
  14. All date arguments are native datetime.date objects.
  15. Pass a custom `clients` dict to substitute stubs for testing.
  16. """
  17. _CLIENT_CLASSES: dict[str, type] = {
  18. 'airbnb': _AirbnbClient,
  19. 'booking': _BookingClient,
  20. }
  21. @classmethod
  22. def get(cls) -> 'ServiceAPI':
  23. """Return the process-wide singleton, creating it on first call."""
  24. global _instance
  25. if _instance is None:
  26. _instance = cls()
  27. return _instance
  28. @classmethod
  29. def set(cls, instance: 'ServiceAPI') -> None:
  30. """Replace the singleton — use in tests to install a stub."""
  31. global _instance
  32. _instance = instance
  33. def __init__(self, clients: dict[str, ServiceClient] | None = None) -> None:
  34. if clients is not None:
  35. self._clients: dict[str, ServiceClient] = clients
  36. self._disabled: dict[str, str] = {}
  37. return
  38. self._clients = {}
  39. self._disabled: dict[str, str] = {}
  40. for key, cls in self._CLIENT_CLASSES.items():
  41. try:
  42. self._clients[key] = cls.from_env()
  43. except Exception as e:
  44. _log.warning('service %s failed to load: %s', key, e)
  45. self._disabled[key] = str(e)
  46. def keys(self) -> list[str]:
  47. """Return the keys of all successfully loaded services."""
  48. return list(self._clients.keys())
  49. def service_name(self, key: str) -> str:
  50. """Return the human-readable display name for a service key."""
  51. cls = self._CLIENT_CLASSES.get(key)
  52. return getattr(cls, 'service_name', key.capitalize()) if cls else key.capitalize()
  53. async def refresh_credentials(self, key: str) -> None:
  54. """Trigger credential refresh for the named service. Raises ServiceAuthError on failure."""
  55. cls = self._CLIENT_CLASSES.get(key)
  56. if cls is None or not hasattr(cls, 'refresh_credentials'):
  57. from .types import ServiceAuthError
  58. raise ServiceAuthError(f'No automatic refresh handler for service: {key}')
  59. loop = asyncio.get_running_loop()
  60. await loop.run_in_executor(None, cls.refresh_credentials)
  61. self.reload_client(key)
  62. def reload_client(self, key: str) -> None:
  63. """Pick up externally updated credentials (env file rewritten by a refresh or PUT).
  64. A live client reloads in place; a service that failed to load at startup is
  65. rebuilt from env so newly supplied credentials can enable it.
  66. """
  67. client = self._clients.get(key)
  68. if client is not None and hasattr(client, '_reload_from_env'):
  69. client._reload_from_env()
  70. return
  71. cls = self._CLIENT_CLASSES.get(key)
  72. if cls is None:
  73. return
  74. try:
  75. self._clients[key] = cls.from_env()
  76. self._disabled.pop(key, None)
  77. except Exception as e:
  78. _log.warning('service %s failed to reload: %s', key, e)
  79. self._clients.pop(key, None)
  80. self._disabled[key] = str(e)
  81. def clear_caches(self) -> int:
  82. """Clear every loaded client's data caches; returns the number of clients cleared."""
  83. cleared = 0
  84. for client in self._clients.values():
  85. if hasattr(client, 'clear_cache'):
  86. client.clear_cache()
  87. cleared += 1
  88. return cleared
  89. def client(self, key: str) -> ServiceClient:
  90. """Return the platform client for `key`, typed as ServiceClient."""
  91. try:
  92. return self._clients[key]
  93. except KeyError:
  94. raise KeyError(f'unknown service: {key!r}') from None
  95. def chat_client(self, key: str) -> ChatClient:
  96. """Return the platform client for `key` if it supports chat; raise TypeError otherwise."""
  97. c = self.client(key)
  98. if not isinstance(c, ChatClient):
  99. raise TypeError(f'{key!r} does not support chat')
  100. return c # type: ignore[return-value]
  101. async def _gather(self, coros: dict[str, object]) -> dict[str, object]:
  102. """Gather per-service results; isolate ServiceAuthError so one failed service can't kill others."""
  103. keys = list(coros.keys())
  104. raw = await asyncio.gather(*coros.values(), return_exceptions=True) # type: ignore[arg-type]
  105. out: dict[str, object] = {}
  106. for key, result in zip(keys, raw):
  107. if isinstance(result, BaseException):
  108. _log.error('service %s error (skipping): %s', key, result, exc_info=not isinstance(result, ServiceAuthError))
  109. out[key] = None
  110. else:
  111. out[key] = result
  112. return out
  113. async def get_prices_range(self, start_date: date, end_date: date) -> dict[str, dict[str, int | None]]:
  114. """Return {date_str: {service_key: price}} for all dates in the range.
  115. Uses each client's bulk get_calendar call so the whole range is fetched
  116. in one pass (month-by-month internally). Price is None for booked or
  117. unavailable dates where the platform does not report a price.
  118. """
  119. ds_from = start_date.isoformat()
  120. ds_to = end_date.isoformat()
  121. cals = await self._gather({
  122. key: client.get_calendar(ds_from, ds_to) for key, client in self._clients.items()
  123. })
  124. all_dates: set[str] = set()
  125. for cal in cals.values():
  126. if cal is not None:
  127. all_dates.update(d for d in cal if ds_from <= d <= ds_to) # type: ignore[union-attr]
  128. result: dict[str, dict[str, int | None]] = {}
  129. for d in sorted(all_dates):
  130. result[d] = {
  131. key: cal[d].price if (cal is not None and d in cal) else None # type: ignore[index]
  132. for key, cal in cals.items()
  133. }
  134. return result
  135. async def get_booked_nets(self, start_date: date, end_date: date) -> dict[str, dict[str, int | None]]:
  136. """Return {date_str: {service_key: net}} for booked dates where per-night net is available.
  137. Only services that implement get_booked_nets contribute data; others are absent from the result.
  138. Dispatch is via hasattr so new clients opt in by implementing the method.
  139. """
  140. ds_from = start_date.isoformat()
  141. ds_to = end_date.isoformat()
  142. per_service = await self._gather({
  143. key: client.get_booked_nets(ds_from, ds_to) # type: ignore[attr-defined]
  144. for key, client in self._clients.items()
  145. if hasattr(client, 'get_booked_nets')
  146. })
  147. result: dict[str, dict[str, float | None]] = {}
  148. for key, nets in per_service.items():
  149. if nets is None:
  150. continue
  151. for d, net in nets.items(): # type: ignore[union-attr]
  152. result.setdefault(d, {})[key] = net
  153. return result
  154. async def get_prices(self, d: date) -> dict[str, int | None]:
  155. """Return {service_key: price} for all services on a single date."""
  156. ds = d.isoformat()
  157. cals = await self._gather({
  158. key: client.get_calendar(ds, ds) for key, client in self._clients.items()
  159. })
  160. return {
  161. key: cal[ds].price if (cal is not None and ds in cal) else None # type: ignore[index]
  162. for key, cal in cals.items()
  163. }
  164. async def set_prices(self, d: date, prices: dict[str, int]) -> None:
  165. """Set price on each specified service; skips booked dates."""
  166. ds = d.isoformat()
  167. _fallback = DayInfo(date=ds, price=None, available=True, booked=False, rooms_to_sell=None)
  168. cals = await self._gather({
  169. key: self._clients[key].get_calendar(ds, ds) for key in prices if key in self._clients
  170. })
  171. to_set = {
  172. key: price
  173. for (key, price) in prices.items()
  174. if cals.get(key) is not None and not cals[key].get(ds, _fallback).booked # type: ignore[union-attr]
  175. }
  176. if to_set:
  177. await self._gather({
  178. key: self._clients[key].set_price(ds, price) for key, price in to_set.items()
  179. })
  180. async def get_available(self, d: date) -> bool:
  181. """Return True only when all active (non-failed) services have the date open."""
  182. ds = d.isoformat()
  183. _closed = DayInfo(date=ds, price=None, available=False, booked=False, rooms_to_sell=None)
  184. cals = await self._gather({
  185. key: client.get_calendar(ds, ds) for key, client in self._clients.items()
  186. })
  187. return all(
  188. cal.get(ds, _closed).available # type: ignore[union-attr]
  189. for cal in cals.values() if cal is not None
  190. )
  191. async def set_available(self, d: date, available: bool) -> None:
  192. """Open or block a date on all services; skips booked dates."""
  193. ds = d.isoformat()
  194. _fallback = DayInfo(date=ds, price=None, available=True, booked=False, rooms_to_sell=None)
  195. cals = await self._gather({
  196. key: client.get_calendar(ds, ds) for key, client in self._clients.items()
  197. })
  198. to_set = {
  199. key: self._clients[key]
  200. for key, cal in cals.items()
  201. if cal is not None and not cal.get(ds, _fallback).booked # type: ignore[union-attr]
  202. }
  203. if to_set:
  204. await self._gather({
  205. key: client.set_availability(ds, available) for key, client in to_set.items()
  206. })
  207. async def get_closed_by_date(self, start_date: date, end_date: date) -> dict[str, list[str]]:
  208. """Return {date_str: [service_keys]} for dates blocked (not available, not booked) on at least one service."""
  209. ds_from = start_date.isoformat()
  210. ds_to = end_date.isoformat()
  211. cals = await self._gather({
  212. key: client.get_calendar(ds_from, ds_to) for key, client in self._clients.items()
  213. })
  214. all_dates: set[str] = set()
  215. for cal in cals.values():
  216. if cal is not None:
  217. all_dates.update(cal.keys()) # type: ignore[union-attr]
  218. result: dict[str, list[str]] = {}
  219. for d in sorted(all_dates):
  220. if not (ds_from <= d <= ds_to):
  221. continue
  222. closed = [
  223. key for key, cal in cals.items()
  224. if cal is not None and (day := cal.get(d)) and not day.available and not day.booked # type: ignore[union-attr]
  225. ]
  226. if closed:
  227. result[d] = closed
  228. return result
  229. async def get_reservations(
  230. self,
  231. start_date: date,
  232. end_date: date,
  233. *,
  234. _out_errors: set | None = None,
  235. ) -> list[dict]:
  236. """Return reservations from all services, sorted by start_date.
  237. Each entry: {service, start_date, end_date, id, guest_name}
  238. Pass a set to _out_errors to collect the keys of services that failed (returned an exception).
  239. """
  240. ds_from = start_date.isoformat()
  241. ds_to = end_date.isoformat()
  242. all_res = await self._gather({
  243. key: client.get_reservations(ds_from, ds_to) for key, client in self._clients.items()
  244. })
  245. result: list[dict] = []
  246. for key, reservations in all_res.items():
  247. if reservations is None:
  248. if _out_errors is not None:
  249. _out_errors.add(key)
  250. continue
  251. for r in reservations: # type: ignore[union-attr]
  252. result.append({
  253. 'service': key,
  254. 'start_date': date.fromisoformat(r.check_in),
  255. 'end_date': date.fromisoformat(r.check_out),
  256. 'id': r.reservation_id,
  257. 'guest_name': r.guest_name,
  258. 'service_data': r.service_data or {},
  259. 'chat_id': r.chat_id,
  260. 'door_code': r.door_code,
  261. })
  262. result.sort(key=lambda x: x['start_date'])
  263. return result
  264. async def diff_remote_sync(self, start_date: date, end_date: date) -> dict[str, list[dict]]:
  265. """Return per-service integrity issues without making any changes."""
  266. ds_from, ds_to = start_date.isoformat(), end_date.isoformat()
  267. results = await self._gather({
  268. key: client.diff_remote_sync(ds_from, ds_to)
  269. for key, client in self._clients.items() if hasattr(client, 'diff_remote_sync')
  270. })
  271. return {key: result for key, result in results.items() if result}
  272. async def remote_sync(self, start_date: date, end_date: date) -> dict[str, list[dict]]:
  273. """Fix per-service integrity issues. Returns per-service list of corrections made."""
  274. ds_from, ds_to = start_date.isoformat(), end_date.isoformat()
  275. results = await self._gather({
  276. key: client.remote_sync(ds_from, ds_to)
  277. for key, client in self._clients.items() if hasattr(client, 'remote_sync')
  278. })
  279. return {key: result for key, result in results.items() if result}