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

1from __future__ import annotations as _annotations 

2 

3import asyncio 

4import logging 

5import threading 

6from dataclasses import dataclass, field 

7from time import time 

8 

9import httpx2 

10 

11from . import data_snapshot 

12 

13__all__ = ( 

14 'DEFAULT_UPDATE_URL', 

15 'UpdatePrices', 

16 'wait_prices_updated_sync', 

17 'wait_prices_updated_async', 

18) 

19 

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 

23 

24 

25def wait_prices_updated_sync(timeout: float | None = None) -> bool: 

26 """Synchronously wait for prices to be updated. 

27 

28 Args: 

29 timeout: The maximum time to wait for prices to be updated. Defaults to None which waits indefinitely. 

30 

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 

37 

38 

39async def wait_prices_updated_async(timeout: float | None = None) -> bool: 

40 """Asynchronously wait for prices to be updated. 

41 

42 Args: 

43 timeout: The maximum time to wait for prices to be updated. Defaults to None which waits indefinitely. 

44 

45 Returns: 

46 True if prices were updated, False otherwise. 

47 """ 

48 return await asyncio.to_thread(wait_prices_updated_sync, timeout) 

49 

50 

51@dataclass 

52class UpdatePrices: 

53 """Update prices in the background using a daemon thread. 

54 

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 """ 

57 

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) 

68 

69 def start(self, *, wait: bool | float = False): 

70 """Start the background task. 

71 

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 

77 

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

80 

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 ) 

85 

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) 

94 

95 def wait(self, timeout: float | None = None) -> bool: 

96 """Wait for the prices to be updated in the background task. 

97 

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 

107 

108 def stop(self): 

109 """Stop the background task.""" 

110 global _global_update_prices 

111 

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 

123 

124 def __enter__(self): 

125 self.start() 

126 return self 

127 

128 def __exit__(self, *_args: object): 

129 self.stop() 

130 

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 

145 

146 finally: 

147 logger.info('voice-prices background task stopped') 

148 

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) 

157 

158 data_snapshot.set_custom_snapshot(snapshot) 

159 

160 def fetch(self) -> data_snapshot.DataSnapshot | None: 

161 """Fetches the latest provider data from the configured URL.""" 

162 from . import data 

163 

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)