Coverage for packages/python/voice_prices/update_prices.py: 91.96%
94 statements
« prev ^ index » next coverage.py v7.9.2, created at 2026-09-27 04:08 +0000
« prev ^ index » next coverage.py v7.9.2, created at 2026-09-27 04:08 +0000
1from __future__ import annotations as _annotations
3import asyncio
4import logging
5import threading
6from dataclasses import dataclass, field
7from time import time
9import httpx2
11from . import data_snapshot
13__all__ = (
14 'DEFAULT_UPDATE_URL',
15 'UpdatePrices',
16 'wait_prices_updated_sync',
17 'wait_prices_updated_async',
18)
20logger = logging.getLogger('voice-prices')
21DEFAULT_UPDATE_URL = 'https://raw.githubusercontent.com/mahimailabs/voice-prices/refs/heads/main/prices/data.json'
22_global_update_prices: UpdatePrices | None = None
25def wait_prices_updated_sync(timeout: float | None = None) -> bool:
26 """Synchronously wait for prices to be updated.
28 Args:
29 timeout: The maximum time to wait for prices to be updated. Defaults to None which waits indefinitely.
31 Returns:
32 True if prices were updated, False otherwise.
33 """
34 if _global_update_prices: 34 ↛ 36line 34 didn't jump to line 36 because the condition on line 34 was always true
35 return _global_update_prices.wait(timeout)
36 return False
39async def wait_prices_updated_async(timeout: float | None = None) -> bool:
40 """Asynchronously wait for prices to be updated.
42 Args:
43 timeout: The maximum time to wait for prices to be updated. Defaults to None which waits indefinitely.
45 Returns:
46 True if prices were updated, False otherwise.
47 """
48 return await asyncio.to_thread(wait_prices_updated_sync, timeout)
51@dataclass
52class UpdatePrices:
53 """Update prices in the background using a daemon thread.
55 Can be used either as a context manager or as a simple class, where you'll need to call start() and stop() manually.
56 """
58 update_interval: float = 3600
59 """How often to update prices in seconds."""
60 url: str = DEFAULT_UPDATE_URL
61 """The URL to fetch prices from."""
62 request_timeout: httpx2.Timeout = field(default_factory=lambda: httpx2.Timeout(timeout=10, connect=5))
63 """The timeout for HTTP requests."""
64 _stop_event: threading.Event = field(default_factory=threading.Event)
65 _prices_updated: threading.Event = field(default_factory=threading.Event)
66 _thread: threading.Thread | None = field(default=None, init=False)
67 _background_exc: Exception | None = field(default=None, init=False)
69 def start(self, *, wait: bool | float = False):
70 """Start the background task.
72 Args:
73 wait: Whether to wait for the prices to be updated before returning, if an int is passed
74 wait for that many seconds, if `True` wait for 30 seconds.
75 """
76 global _global_update_prices
78 if self._thread is not None: 78 ↛ 79line 78 didn't jump to line 79 because the condition on line 78 was never true
79 raise RuntimeError('UpdatePrices background task already started')
81 if _global_update_prices is not None:
82 raise RuntimeError(
83 'UpdatePrices global task already started, only one UpdatePrices can be active at a time'
84 )
86 _global_update_prices = self
87 self._prices_updated.clear()
88 self._stop_event.clear()
89 self._background_exc = None
90 self._thread = threading.Thread(target=self._background_task, daemon=True, name='voice_prices:update')
91 self._thread.start()
92 if wait: 92 ↛ 93line 92 didn't jump to line 93 because the condition on line 92 was never true
93 self.wait(timeout=30 if wait is True else wait)
95 def wait(self, timeout: float | None = None) -> bool:
96 """Wait for the prices to be updated in the background task.
98 Args:
99 timeout: The maximum time to wait for the prices to be updated in seconds.
100 """
101 prices_updated = self._prices_updated.wait(timeout=timeout)
102 exc = self._background_exc
103 if exc:
104 self._background_exc = None
105 raise exc
106 return prices_updated
108 def stop(self):
109 """Stop the background task."""
110 global _global_update_prices
112 _global_update_prices = None
113 if self._thread is not None:
114 self._stop_event.set()
115 self._thread.join()
116 self._thread = None
117 # Clear after the thread exits so an in-flight fetch cannot reinstall a snapshot after stop().
118 data_snapshot.set_custom_snapshot(None)
119 if self._background_exc:
120 exc = self._background_exc
121 self._background_exc = None
122 raise exc
124 def __enter__(self):
125 self.start()
126 return self
128 def __exit__(self, *_args: object):
129 self.stop()
131 def _background_task(self) -> None:
132 logger.info('Starting voice-prices background task')
133 try:
134 while True:
135 try:
136 self._update_prices()
137 self._prices_updated.set()
138 self._background_exc = None
139 except Exception as e:
140 self._background_exc = e
141 self._prices_updated.set()
142 logger.error('Error updating voice-prices in the background (%s): %s', type(e).__name__, e)
143 if self._stop_event.wait(self.update_interval): 143 ↛ 134line 143 didn't jump to line 134 because the condition on line 143 was always true
144 break
146 finally:
147 logger.info('voice-prices background task stopped')
149 def _update_prices(self):
150 start = time()
151 snapshot = self.fetch()
152 interval = time() - start
153 if snapshot: 153 ↛ 156line 153 didn't jump to line 156 because the condition on line 153 was always true
154 logger.info('Successfully fetched %d providers in %.2f seconds', len(snapshot.providers), interval)
155 else:
156 logger.info('Successfully fetched null snapshot in %.2f seconds', interval)
158 data_snapshot.set_custom_snapshot(snapshot)
160 def fetch(self) -> data_snapshot.DataSnapshot | None:
161 """Fetches the latest provider data from the configured URL."""
162 from . import data
164 r = httpx2.get(self.url, timeout=self.request_timeout)
165 r.raise_for_status()
166 return data_snapshot.DataSnapshot(data.providers_schema.validate_json(r.content), from_auto_update=True)