Coverage for src/lilbee/cli/tui/screens/analyze_report.py: 100%

203 statements  

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

1"""The /analyze task and the report it opens: findings, the recommended profile, Apply or save.""" 

2 

3from __future__ import annotations 

4 

5from collections.abc import Callable, Mapping, Sequence 

6from dataclasses import dataclass 

7from enum import StrEnum 

8from pathlib import Path 

9from typing import TYPE_CHECKING, ClassVar, NoReturn 

10 

11from textual import on 

12from textual.app import ComposeResult 

13from textual.binding import Binding, BindingType 

14from textual.containers import Horizontal, VerticalScroll 

15from textual.content import Content 

16from textual.screen import Screen 

17from textual.widgets import DataTable, Footer, Static 

18 

19from lilbee.app import profiles 

20from lilbee.app.analyze import ( 

21 PICK_REASON_KEY, 

22 AnalyzeReport, 

23 AnalyzeRequest, 

24 LanguageRow, 

25 Recommendation, 

26 run_analysis, 

27) 

28from lilbee.cli.tui import messages as msg 

29from lilbee.cli.tui.browse_bindings import browse_back_bindings 

30from lilbee.cli.tui.screens.profile_dialogs import ( 

31 add_cost_column, 

32 diff_cells, 

33 run_profile_op, 

34 shorten_middle, 

35 start_profile_switch, 

36 start_switch, 

37 switch_plan, 

38) 

39from lilbee.cli.tui.task_queue import PERCENT, TaskType 

40from lilbee.cli.tui.thread_safe import call_from_thread 

41from lilbee.cli.tui.widgets.bottom_bars import BottomBars 

42from lilbee.cli.tui.widgets.confirm_dialog import ConfirmPill 

43from lilbee.cli.tui.widgets.status_bar import ViewTabs 

44from lilbee.cli.tui.widgets.task_bar import TaskBar 

45from lilbee.cli.tui.widgets.top_bars import TopBars 

46from lilbee.core.profile_files import ProfileStore 

47from lilbee.data.analyze import CorpusSignals 

48from lilbee.runtime import asyncio_loop 

49from lilbee.runtime.progress import AnalyzeEvent, EventType 

50 

51if TYPE_CHECKING: 

52 from lilbee.cli.tui.app import LilbeeApp 

53 from lilbee.cli.tui.widgets.task_bar_controller import ProgressReporter 

54 from lilbee.runtime.progress import ProgressEvent 

55 

56 

57class ReportAction(StrEnum): 

58 """The report's action pills.""" 

59 

60 APPLY = "apply" 

61 SAVE = "save" 

62 CLOSE = "close" 

63 

64 

65_REASON_WIDTH = 48 # the longest reason a failed-file row shows 

66 

67 

68@dataclass(frozen=True) 

69class FinishedAnalysis: 

70 """A finished analyze run: its report and the folder it read.""" 

71 

72 report: AnalyzeReport 

73 folder: str 

74 

75 

76def _short_reason(error: str) -> str: 

77 """The first line of a parser's *error*, cut to fit a table cell.""" 

78 lines = error.strip().splitlines() 

79 return shorten_middle(lines[0].strip() if lines else "", _REASON_WIDTH) 

80 

81 

82def start_analysis(app: LilbeeApp, directory: Path | None) -> None: 

83 """Queue analyze of *directory*, or of the indexed corpus when None; a toast says when done.""" 

84 folder = str(directory) if directory is not None else msg.ANALYZE_YOUR_DOCUMENTS 

85 

86 def _target(reporter: ProgressReporter) -> None: 

87 report = _analyze(app, directory, reporter) 

88 call_from_thread(app, _finished, app, FinishedAnalysis(report, folder)) 

89 

90 shown = (directory.name or folder) if directory is not None else folder 

91 name = msg.TASK_NAME_ANALYZE.format(folder=shown) 

92 app.task_bar.start_task(name, TaskType.ANALYZE, _target, indeterminate=True) 

93 

94 

95def _finished(app: LilbeeApp, analysis: FinishedAnalysis) -> None: 

96 """Keep the report for /analyze report; it never opens over what the user is typing.""" 

97 app.last_analysis = analysis 

98 app.notify(msg.ANALYZE_DONE.format(folder=analysis.folder)) 

99 

100 

101def open_report(app: LilbeeApp) -> None: 

102 """Open the last finished analyze report, or say there is none yet.""" 

103 analysis = app.last_analysis 

104 if analysis is None: 

105 app.notify(msg.ANALYZE_NO_REPORT, severity="warning") 

106 return 

107 app.push_screen(AnalyzeReportScreen(analysis.report, analysis.folder)) 

108 

109 

110def _analyze(app: LilbeeApp, directory: Path | None, reporter: ProgressReporter) -> AnalyzeReport: 

111 """Run analyze on the task worker; a refusal toasts its reason and fails the task.""" 

112 reporter.update(0, msg.ANALYZE_STATUS_FINDING, indeterminate=True) 

113 request = AnalyzeRequest(directory=directory) 

114 

115 def on_progress(event_type: EventType, data: ProgressEvent) -> None: 

116 # the callback carries every event type; only analyze events move the bar 

117 if event_type is EventType.ANALYZE and isinstance(data, AnalyzeEvent): 

118 percent = data.done * PERCENT // data.total if data.total else 0 

119 detail = msg.ANALYZE_STATUS_READING.format( 

120 done=data.done, total=data.total, file=data.file 

121 ) 

122 reporter.update(percent, detail, indeterminate=False) 

123 

124 run = run_analysis(ProfileStore(), request, on_progress=on_progress, cancel=reporter) 

125 try: 

126 return asyncio_loop.run(run) 

127 except ValueError as exc: 

128 _refused(app, str(exc), exc) 

129 except OSError as exc: 

130 _refused(app, profiles.file_failure_message(exc), exc) 

131 

132 

133def _refused(app: LilbeeApp, text: str, exc: Exception) -> NoReturn: 

134 """Toast why analyze stopped, then fail the task with the same reason.""" 

135 call_from_thread(app, app.notify, msg.ANALYZE_FAILED.format(error=text), severity="error") 

136 raise RuntimeError(text) from exc 

137 

138 

139def reading_text(signals: CorpusSignals) -> str: 

140 """How many documents analyze read and counted, and whether it sampled them.""" 

141 template = msg.ANALYZE_READ_COUNTED if signals.files_counted else msg.ANALYZE_READ 

142 text = template.format( 

143 read=signals.files_read, total=signals.documents_total, counted=signals.files_counted 

144 ) 

145 if signals.files_read + len(signals.failed) < signals.documents_total: 

146 return f"{text} {msg.ANALYZE_SAMPLED.format(cap=signals.cap)}" 

147 return text 

148 

149 

150def _types_text(file_types: Mapping[str, int]) -> str: 

151 ordered = sorted(file_types.items(), key=lambda item: (-item[1], item[0])) 

152 counts = [msg.ANALYZE_TYPE_COUNT.format(count=count, kind=kind) for kind, count in ordered] 

153 return ", ".join(counts) or msg.PROFILE_VALUE_NONE 

154 

155 

156def _languages_text(rows: Sequence[LanguageRow]) -> str: 

157 shares = [ 

158 msg.ANALYZE_LANGUAGE_SHARE.format(name=row.fts_language or row.code, share=row.share) 

159 for row in rows 

160 ] 

161 if not shares: 

162 return msg.ANALYZE_LANGUAGES_NONE 

163 return msg.ANALYZE_LANGUAGES_TEXT.format(languages=", ".join(shares)) 

164 

165 

166def fact_rows(report: AnalyzeReport) -> list[tuple[str, str]]: 

167 """The Found table: each signal analyze read, left out when there is nothing to show.""" 

168 signals = report.signals 

169 pdf = signals.pdf 

170 rows: list[tuple[str, str | None]] = [ 

171 (msg.ANALYZE_FACT_TYPES, _types_text(signals.file_types)), 

172 ( 

173 msg.ANALYZE_FACT_CODE, 

174 msg.ANALYZE_SHARE_OF_FILES.format(share=signals.code_share) 

175 if signals.code_share 

176 else None, 

177 ), 

178 ( 

179 msg.ANALYZE_FACT_SCANS, 

180 msg.ANALYZE_SHARE_OF_PAGES.format(share=pdf.scanned_share) 

181 if pdf.pages or signals.image_files 

182 else None, 

183 ), 

184 ( 

185 msg.ANALYZE_FACT_TABLES, 

186 msg.ANALYZE_TABLES_VALUE.format(tables=pdf.files_with_tables, files=pdf.files) 

187 if pdf.files 

188 else None, 

189 ), 

190 (msg.ANALYZE_FACT_LANGUAGES, _languages_text(report.languages)), 

191 ( 

192 msg.ANALYZE_FACT_PDF_LENGTH, 

193 None 

194 if pdf.median_pages is None 

195 else msg.ANALYZE_PAGES_VALUE.format(pages=pdf.median_pages), 

196 ), 

197 ( 

198 msg.ANALYZE_FACT_TEXT_LENGTH, 

199 None 

200 if signals.median_chars is None 

201 else msg.ANALYZE_CHARS_VALUE.format(chars=signals.median_chars), 

202 ), 

203 ] 

204 return [(label, value) for label, value in rows if value is not None] 

205 

206 

207def _table(table_id: str, *columns: str) -> DataTable[str | Content]: 

208 table: DataTable[str | Content] = DataTable( 

209 id=table_id, cursor_type="none", zebra_stripes=False, show_cursor=False 

210 ) 

211 table.add_columns(*columns) 

212 # read-only tables: focus moves between the scroll area and the action pills 

213 table.can_focus = False 

214 return table 

215 

216 

217def change_rows(recommendation: Recommendation) -> list[Sequence[str | Content]]: 

218 """The changes table: each setting applying changes, its cost, and why analyze set it.""" 

219 why = {reason.key: reason.text for reason in recommendation.reasons} 

220 # every adjustment carries a reason, so a change without one is the built-in's own value 

221 builtin = msg.ANALYZE_WHY_BUILTIN.format(builtin=recommendation.builtin) 

222 return [(*diff_cells(row), why.get(row.key, builtin)) for row in recommendation.changes] 

223 

224 

225def _recommend_text(recommendation: Recommendation) -> str: 

226 template = ( 

227 msg.ANALYZE_RECOMMEND_DERIVED if recommendation.name else msg.ANALYZE_RECOMMEND_BUILTIN 

228 ) 

229 text = template.format(builtin=recommendation.builtin) 

230 picked = [r.text for r in recommendation.reasons if r.key == PICK_REASON_KEY] 

231 return " ".join([text, *(msg.ANALYZE_PICKED.format(reason=reason) for reason in picked)]) 

232 

233 

234class AnalyzeReportScreen(Screen[None]): 

235 """What analyze found and the profile it recommends; nothing changes until Apply or Save.""" 

236 

237 CSS_PATH = "analyze_report.tcss" 

238 # the read-only scroll area: no key acts until the user moves to a pill 

239 AUTO_FOCUS = "#analyze-report" 

240 

241 BINDINGS: ClassVar[list[BindingType]] = [ 

242 *browse_back_bindings(), 

243 Binding("left", "app.focus_previous", "Previous", show=False), 

244 Binding("right", "app.focus_next", "Next", show=False), 

245 ] 

246 

247 app: LilbeeApp # type: ignore[assignment] 

248 

249 def __init__(self, report: AnalyzeReport, folder: str) -> None: 

250 super().__init__() 

251 self._report = report 

252 self._folder = folder 

253 self._actions: dict[ReportAction, Callable[[], None]] = { 

254 ReportAction.APPLY: self.action_apply, 

255 ReportAction.SAVE: self.action_save, 

256 ReportAction.CLOSE: self.action_go_back, 

257 } 

258 

259 def compose(self) -> ComposeResult: 

260 with TopBars(): 

261 yield ViewTabs() 

262 with VerticalScroll(id="analyze-report"): 

263 yield Static( 

264 msg.ANALYZE_TITLE.format(folder=self._folder), classes="analyze-heading first" 

265 ) 

266 yield Static(reading_text(self._report.signals), classes="analyze-muted") 

267 facts = _table("analyze-facts", msg.ANALYZE_FOUND, "") 

268 facts.add_rows(fact_rows(self._report)) 

269 yield facts 

270 yield from self._compose_failures() 

271 yield from self._compose_recommendation() 

272 yield from self._compose_notes() 

273 with Horizontal(id="analyze-actions"): 

274 yield ConfirmPill( 

275 msg.PROFILE_APPLY_LABEL, 

276 pill_id="analyze-apply", 

277 answer=ReportAction.APPLY, 

278 primary=True, 

279 ) 

280 if self._report.recommendation.name is not None: 

281 yield ConfirmPill( 

282 msg.ANALYZE_SAVE_LABEL, pill_id="analyze-save", answer=ReportAction.SAVE 

283 ) 

284 yield ConfirmPill( 

285 msg.PROFILE_KEY_CLOSE, pill_id="analyze-close", answer=ReportAction.CLOSE 

286 ) 

287 with BottomBars(): 

288 yield TaskBar() 

289 yield Footer() 

290 

291 def _compose_failures(self) -> ComposeResult: 

292 folders = self._report.signals.unreadable_folders 

293 if folders: 

294 yield Static(msg.ANALYZE_UNREADABLE_TITLE, classes="analyze-heading") 

295 table = _table("analyze-unreadable", msg.ANALYZE_COL_FOLDER) 

296 table.add_rows((folder,) for folder in folders) 

297 yield table 

298 failed = self._report.signals.failed 

299 if not failed: 

300 return 

301 yield Static(msg.ANALYZE_FAILED_TITLE, classes="analyze-heading") 

302 table = _table("analyze-failed", msg.ANALYZE_COL_FILE, msg.ANALYZE_COL_REASON) 

303 table.add_rows((failure.file, _short_reason(failure.error)) for failure in failed) 

304 yield table 

305 

306 def _compose_recommendation(self) -> ComposeResult: 

307 rec = self._report.recommendation 

308 title = msg.ANALYZE_RECOMMEND_TITLE.format(name=rec.name or rec.builtin) 

309 yield Static(title, classes="analyze-heading", markup=False) 

310 yield Static(_recommend_text(rec), classes="analyze-muted", markup=False) 

311 if not rec.changes: 

312 yield Static(msg.ANALYZE_NO_CHANGES, classes="analyze-muted") 

313 else: 

314 table = _table( 

315 "analyze-changes", 

316 msg.PROFILE_COL_SETTING, 

317 msg.PROFILE_COL_NOW, 

318 msg.PROFILE_COL_AFTER, 

319 ) 

320 add_cost_column(table) 

321 table.add_column(msg.ANALYZE_COL_WHY) 

322 table.add_rows(change_rows(rec)) 

323 yield table 

324 if rec.kept: 

325 keys = ", ".join(rec.kept) 

326 yield Static(msg.ANALYZE_KEEPS.format(keys=keys), classes="analyze-muted") 

327 

328 def _compose_notes(self) -> ComposeResult: 

329 notes = self._report.recommendation.notes 

330 if not notes: 

331 return 

332 yield Static(msg.ANALYZE_NOTES_TITLE, classes="analyze-heading") 

333 for note in notes: 

334 yield Static(note, classes="analyze-muted", markup=False) 

335 

336 @on(ConfirmPill.Picked) 

337 def _on_picked(self, event: ConfirmPill.Picked) -> None: 

338 event.stop() 

339 self._actions[ReportAction(str(event.answer))]() 

340 

341 def action_apply(self) -> None: 

342 """Ask with the Apply dialog, then save the recommended profile and switch to it.""" 

343 rec = self._report.recommendation 

344 name = rec.name 

345 if name is None: 

346 start_profile_switch(self.app, self, rec.builtin, _ignore, self._applied) 

347 return 

348 values = rec.values 

349 start_switch( 

350 self.app, 

351 self, 

352 lambda: switch_plan( 

353 profiles.recommended_file(name, values), profiles.preview(name, values) 

354 ), 

355 lambda: profiles.apply_recommended( 

356 ProfileStore(), name, values, profiles.default_save_folder() 

357 ), 

358 on_close=_ignore, 

359 on_applied=self._applied, 

360 ) 

361 

362 def action_save(self) -> None: 

363 """Save the recommended profile without switching to it.""" 

364 rec = self._report.recommendation 

365 name = rec.name 

366 if name is None: 

367 return 

368 values = rec.values 

369 run_profile_op( 

370 self, 

371 lambda: profiles.save_recommended( 

372 ProfileStore(), name, values, profiles.default_save_folder(), switch=False 

373 ), 

374 self._saved, 

375 ) 

376 

377 def _saved(self, result: profiles.SaveResult) -> None: 

378 location = result.location 

379 self.notify(msg.PROFILE_SAVED.format(name=location.name, path=location.path)) 

380 

381 def _applied(self) -> None: 

382 self.dismiss() 

383 

384 def action_go_back(self) -> None: 

385 self.dismiss() 

386 

387 

388def _ignore() -> None: 

389 """Keep the report open when the Apply dialog closes without applying."""