Coverage for src/lilbee/cli/sync.py: 100%

121 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-11-06 08:17 +0000

1"""Background sync, executor management, and sync status for chat mode.""" 

2 

3from __future__ import annotations 

4 

5import asyncio 

6import threading 

7from collections.abc import Callable 

8from concurrent.futures import Future, ThreadPoolExecutor 

9from typing import TYPE_CHECKING 

10 

11from rich.text import Text 

12 

13from lilbee.cli import theme 

14from lilbee.cli.helpers import print_prefixed 

15from lilbee.data.ingest import sync 

16from lilbee.runtime.asyncio_loop import is_executor_shutdown 

17from lilbee.runtime.console import PlainConsole 

18from lilbee.runtime.progress import ( 

19 EventType, 

20 ExtractEvent, 

21 FileStartEvent, 

22 OcrStartEvent, 

23 ProgressEvent, 

24 SyncDoneEvent, 

25 SyncWaitEvent, 

26) 

27 

28if TYPE_CHECKING: 

29 from lilbee.runtime.progress import DetailedProgressCallback 

30 

31 

32def _format_sync_summary( 

33 added: int, updated: int, removed: int, failed: int, skipped: int = 0, relocated: int = 0 

34) -> str | None: 

35 """Format sync counts into a human-readable summary, or None if nothing changed.""" 

36 counts = { 

37 "added": added, 

38 "updated": updated, 

39 "removed": removed, 

40 "relocated": relocated, 

41 "skipped": skipped, 

42 "failed": failed, 

43 } 

44 parts = [f"{n} {label}" for label, n in counts.items() if n] 

45 return ", ".join(parts) if parts else None 

46 

47 

48def _print_file_start(con: PlainConsole, data: ProgressEvent) -> None: 

49 if not isinstance(data, FileStartEvent): 

50 raise TypeError(f"Expected FileStartEvent, got {type(data).__name__}") 

51 con.print( 

52 Text( 

53 f"Syncing [{data.current_file}/{data.total_files}]: {data.file}", 

54 style=theme.MUTED, 

55 ), 

56 soft_wrap=True, 

57 ) 

58 

59 

60def _print_done(con: PlainConsole, data: ProgressEvent) -> None: 

61 if not isinstance(data, SyncDoneEvent): 

62 raise TypeError(f"Expected SyncDoneEvent, got {type(data).__name__}") 

63 summary = _format_sync_summary( 

64 data.added, data.updated, data.removed, data.failed, data.skipped, data.relocated 

65 ) 

66 if summary: 

67 con.print(f"Synced: {summary}", style=theme.MUTED) 

68 

69 

70def _print_wait(con: PlainConsole, data: ProgressEvent) -> None: 

71 if not isinstance(data, SyncWaitEvent): 

72 raise TypeError(f"Expected SyncWaitEvent, got {type(data).__name__}") 

73 con.print(data.message, style=theme.MUTED, soft_wrap=True) 

74 

75 

76def _sync_progress_printer(con: PlainConsole) -> DetailedProgressCallback: 

77 """Return a callback that prints one-line status for FILE_START, SYNC_WAIT and SYNC_DONE.""" 

78 handlers: dict[EventType, Callable[[PlainConsole, ProgressEvent], None]] = { 

79 EventType.FILE_START: _print_file_start, 

80 EventType.SYNC_WAIT: _print_wait, 

81 EventType.SYNC_DONE: _print_done, 

82 } 

83 

84 def _callback(event_type: EventType, data: ProgressEvent) -> None: 

85 handler = handlers.get(event_type) 

86 if handler is not None: 

87 handler(con, data) 

88 

89 return _callback 

90 

91 

92def file_start_printer(con: PlainConsole) -> DetailedProgressCallback: 

93 """Return a callback that prints one line as each file starts, for output with no live bar.""" 

94 

95 def _callback(event_type: EventType, data: ProgressEvent) -> None: 

96 if event_type == EventType.FILE_START: 

97 _print_file_start(con, data) 

98 

99 return _callback 

100 

101 

102_bg_executor: ThreadPoolExecutor | None = None 

103 

104 

105def _get_executor() -> ThreadPoolExecutor: 

106 """Lazy-init a single-worker executor.""" 

107 global _bg_executor 

108 if _bg_executor is None: 

109 _bg_executor = ThreadPoolExecutor(max_workers=1) 

110 return _bg_executor 

111 

112 

113def shutdown_executor() -> None: 

114 """Shut down the background executor without blocking. 

115 Uses wait=False + cancel_futures to avoid blocking the main thread. 

116 """ 

117 global _bg_executor 

118 if _bg_executor is None: 

119 return 

120 

121 _bg_executor.shutdown(wait=False, cancel_futures=True) 

122 _bg_executor = None 

123 

124 

125def _on_sync_done(con: PlainConsole, future: Future[object], *, chat_mode: bool = False) -> None: 

126 """Callback attached to background sync futures: logs errors.""" 

127 exc = future.exception() 

128 if exc is None: 

129 return 

130 if isinstance(exc, asyncio.CancelledError): 

131 return 

132 if is_executor_shutdown(exc): 

133 return 

134 if chat_mode: 

135 print(f"Background sync error: {exc}") 

136 else: 

137 print_prefixed(con, "Background sync error: ", exc, style=theme.ERROR) 

138 

139 

140class SyncStatus: 

141 """Thread-safe holder for background sync status text. 

142 The background sync callback writes here; prompt_toolkit's 

143 ``bottom_toolbar`` reads it on every render cycle: no cursor 

144 manipulation, no flickering. 

145 """ 

146 

147 def __init__(self) -> None: 

148 self.text: str = "" 

149 self.pending: int = 0 

150 self._pending_lock = threading.Lock() 

151 

152 def clear(self) -> None: 

153 self.text = "" 

154 

155 def adjust_pending(self, delta: int) -> None: 

156 """Atomically change the queued-sync counter (mutated from two threads).""" 

157 with self._pending_lock: 

158 self.pending += delta 

159 

160 

161def _tesseract_status(data: ProgressEvent) -> str: 

162 """The status line for an OCR_START event: Tesseract running on the file's pages.""" 

163 if not isinstance(data, OcrStartEvent): 

164 raise TypeError(f"Expected OcrStartEvent, got {type(data).__name__}") 

165 return f"⟳ {data.status_text}" 

166 

167 

168def _chat_status_line(event_type: EventType, data: ProgressEvent) -> str | None: 

169 """The status line an event sets in chat mode, or None for an event that sets none.""" 

170 if event_type == EventType.FILE_START: 

171 if not isinstance(data, FileStartEvent): 

172 raise TypeError(f"Expected FileStartEvent, got {type(data).__name__}") 

173 return f"⟳ Syncing [{data.current_file}/{data.total_files}]: {data.file}" 

174 if event_type == EventType.EXTRACT: 

175 if not isinstance(data, ExtractEvent): 

176 raise TypeError(f"Expected ExtractEvent, got {type(data).__name__}") 

177 return f"⟳ {data.step} [{data.page}/{data.total_pages}]: {data.file}" 

178 if event_type == EventType.OCR_START: 

179 return _tesseract_status(data) 

180 if event_type == EventType.SYNC_WAIT: 

181 if not isinstance(data, SyncWaitEvent): 

182 raise TypeError(f"Expected SyncWaitEvent, got {type(data).__name__}") 

183 return f"⟳ {data.message}" 

184 return None 

185 

186 

187def _chat_sync_callback(status: SyncStatus) -> DetailedProgressCallback: 

188 """Return a progress callback for chat-mode background sync. 

189 FILE_START updates *status.text* (rendered by prompt_toolkit's bottom 

190 toolbar). On DONE the status is cleared and the summary is printed via 

191 ``print()`` (goes through StdoutProxy → appears above the prompt). 

192 """ 

193 status.clear() 

194 

195 def _callback(event_type: EventType, data: ProgressEvent) -> None: 

196 line = _chat_status_line(event_type, data) 

197 if line is not None: 

198 queue_suffix = f" (+{status.pending} queued)" if status.pending > 0 else "" 

199 status.text = line + queue_suffix 

200 elif event_type == EventType.SYNC_DONE: 

201 status.clear() 

202 if not isinstance(data, SyncDoneEvent): 

203 raise TypeError(f"Expected SyncDoneEvent, got {type(data).__name__}") 

204 summary = _format_sync_summary( 

205 data.added, data.updated, data.removed, data.failed, data.skipped, data.relocated 

206 ) 

207 if summary: 

208 print(f"✓ Synced: {summary}") 

209 

210 return _callback 

211 

212 

213def run_sync_background( 

214 con: PlainConsole, 

215 *, 

216 chat_mode: bool = False, 

217 sync_status: SyncStatus | None = None, 

218) -> Future[object]: 

219 """Submit sync to a background thread. Returns the Future.""" 

220 status = sync_status or SyncStatus() 

221 

222 callback = _chat_sync_callback(status) if chat_mode else _sync_progress_printer(con) 

223 

224 def _run() -> object: 

225 if chat_mode: 

226 status.adjust_pending(-1) 

227 return asyncio.run(sync(quiet=True, on_progress=callback, started_by="lilbee chat")) 

228 

229 if chat_mode: 

230 status.adjust_pending(1) 

231 

232 future = _get_executor().submit(_run) 

233 future.add_done_callback(lambda f: _on_sync_done(con, f, chat_mode=chat_mode)) 

234 return future