Coverage for src/lilbee/cli/commands/profile.py: 100%

264 statements  

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

1"""Profile commands: show, list, diff, apply, and the profile file operations.""" 

2 

3from __future__ import annotations 

4 

5from pathlib import Path 

6from typing import TYPE_CHECKING, Any 

7 

8import typer 

9from rich.table import Table 

10from rich.text import Text 

11 

12from lilbee.cli import theme 

13from lilbee.cli.app import apply_overrides, console, data_dir_option, global_option 

14from lilbee.cli.commands._shared import REBUILD_HINT, emit, line, run_or_fail, shown_value 

15from lilbee.cli.helpers import json_output, print_prefixed 

16from lilbee.core.config import cfg 

17from lilbee.core.profile_files import ProfileFolder, ProfileStore, profile_key 

18 

19if TYPE_CHECKING: 

20 from lilbee.server.models import ( 

21 ActiveProfileResponse, 

22 ProfileApplyResponse, 

23 ProfileChangeRowResponse, 

24 ProfileDiffResponse, 

25 ProfileDiffRowResponse, 

26 ProfileDiscardResponse, 

27 ProfileEntryResponse, 

28 ProfileLocationResponse, 

29 ProfileSaveResponse, 

30 ProfileValidationResponse, 

31 ) 

32 

33_CURRENT_DIR = Path() 

34 

35profile_app = typer.Typer( 

36 help="Manage profiles: named sets of ingest, OCR, chunking and retrieval settings.", 

37 no_args_is_help=True, 

38) 

39 

40_target_option = typer.Option( 

41 ProfileFolder.GLOBAL, 

42 "--target", 

43 help="Where to save it: global (all projects, the default) or project (this project).", 

44) 

45_folder_option = typer.Option( 

46 ProfileFolder.GLOBAL, 

47 "--folder", 

48 help="Check it as a profile in this folder; builtin applies the evidence rule.", 

49) 

50_reindex_option = typer.Option( 

51 False, 

52 "--reindex", 

53 help="Rebuild the index afterwards when the removed values need it.", 

54) 

55_export_path_argument = typer.Argument(_CURRENT_DIR, help="A file, or a folder for <name>.toml.") 

56_name_argument = typer.Argument(help="The name of an existing profile.") 

57_new_name_argument = typer.Argument(help="The name the profile gets.") 

58_file_argument = typer.Argument(help="A profile file.") 

59 

60 

61def _store() -> ProfileStore: 

62 """The profile store.""" 

63 return ProfileStore() 

64 

65 

66def _about(entry: ProfileEntryResponse) -> list[str]: 

67 """The description, credit, tested-on and problem lines shown for a profile.""" 

68 from lilbee.app import profiles 

69 

70 tested_on = profiles.tested_on_line(entry.tested_on, "Tested on {text}") 

71 lines = [entry.description, entry.credit, tested_on] 

72 if entry.error: 

73 lines.append(f"Broken: {entry.error}") 

74 if entry.shadowed_by is not None: 

75 lines.append(f"Hidden by the {entry.shadowed_by.value} profile with this name") 

76 return [text for text in lines if text] 

77 

78 

79def _render_entry(entry: ProfileEntryResponse) -> None: 

80 line(entry.name, theme.ACCENT) 

81 for about_line in _about(entry): 

82 line(about_line) 

83 line(f"{entry.folder.value} profile: {entry.path}", theme.MUTED) 

84 for key, value in entry.values.items(): 

85 line(f" {key} = {shown_value(value)}") 

86 

87 

88def _render_list(entries: list[ProfileEntryResponse], active_name: str) -> None: 

89 active_key = profile_key(active_name) 

90 table = Table("", "Name", "Folder", "About") 

91 for entry in entries: 

92 picked = entry.shadowed_by is None and profile_key(entry.name) == active_key 

93 marker = "*" if picked else "" 

94 table.add_row(marker, Text(entry.name), entry.folder.value, Text("\n".join(_about(entry)))) 

95 console.print(table) 

96 

97 

98def _render_active(current: ActiveProfileResponse) -> None: 

99 from lilbee.app.profiles import status_note 

100 

101 line(f"Profile: {current.name}", theme.ACCENT) 

102 note = status_note(current.status) 

103 if note: 

104 line(note, theme.WARNING) 

105 if current.error: 

106 line(current.error, theme.WARNING) 

107 if current.profile is not None: 

108 for about_line in _about(current.profile): 

109 line(about_line) 

110 if current.changes: 

111 _render_your_changes(current.changes) 

112 

113 

114def _render_your_changes(rows: list[ProfileChangeRowResponse]) -> None: 

115 line("Your changes:", theme.ACCENT) 

116 table = Table("Setting", "Yours", "Without yours", "Takes effect") 

117 for row in rows: 

118 source = row.profile_source.value.replace("_", " ") 

119 fallback = f"{shown_value(row.profile_value)} ({source})" 

120 effect = row.effect.value.replace("_", " ") 

121 table.add_row(row.key, Text(shown_value(row.yours)), Text(fallback), effect) 

122 console.print(table) 

123 

124 

125def render_changes(rows: list[ProfileDiffRowResponse]) -> None: 

126 """Print the settings a profile apply changes, as a table.""" 

127 if not rows: 

128 line("No settings change.") 

129 return 

130 table = Table("Setting", "Now", "After", "Takes effect") 

131 for row in rows: 

132 source = row.current_source.value.replace("_", " ") 

133 now = f"{shown_value(row.current)} ({source})" 

134 effect = row.effect.value.replace("_", " ") 

135 table.add_row(row.key, Text(now), Text(shown_value(row.new)), effect) 

136 console.print(table) 

137 

138 

139def _render_diff(diff: ProfileDiffResponse) -> None: 

140 line(f"Applying {diff.name}:", theme.ACCENT) 

141 render_changes(diff.changes) 

142 if diff.kept: 

143 line(f"Keeps your values of: {', '.join(diff.kept)}") 

144 line(f"{diff.untouched_count} settings are never touched by profiles.", theme.MUTED) 

145 

146 

147def _render_location(verb: str, location: ProfileLocationResponse) -> None: 

148 line(f"{verb} {location.name}: {location.path}") 

149 

150 

151def _render_save(verb: str, result: ProfileSaveResponse) -> None: 

152 _render_location(verb, result) 

153 if result.absorbed: 

154 line(f"It now holds your settings of: {', '.join(result.absorbed)}") 

155 for warning in result.warnings: 

156 line(warning, theme.WARNING) 

157 

158 

159def _render_rebuild( 

160 reindex_required: bool, reindexed: int | None, reindex_error: str | None 

161) -> None: 

162 """Print the outcome of a --reindex rebuild, and the rebuild hint when the index is stale.""" 

163 if reindexed is not None: 

164 line(f"Rebuilt: {reindexed} documents ingested") 

165 if reindex_error is not None: 

166 print_prefixed(console, "Error: ", reindex_error, style=theme.ERROR) 

167 if reindex_required and (reindexed is None or reindex_error is not None): 

168 line(REBUILD_HINT, theme.WARNING) 

169 

170 

171def _render_discard( 

172 result: ProfileDiscardResponse, reindexed: int | None, reindex_error: str | None 

173) -> None: 

174 if not result.dropped: 

175 line("You have no values of profile settings to remove.") 

176 return 

177 line(f"Removed your values of: {', '.join(result.dropped)}") 

178 _render_rebuild(result.reindex_required, reindexed, reindex_error) 

179 for warning in result.warnings: 

180 line(warning, theme.WARNING) 

181 

182 

183@profile_app.command(name="show") 

184def profile_show( 

185 name: str | None = typer.Argument(None, help="A profile name; omit it for this project's."), 

186 data_dir: Path | None = data_dir_option, 

187 use_global: bool = global_option, 

188) -> None: 

189 """Show this project's profile, or the profile a name picks.""" 

190 from lilbee.app import profiles 

191 from lilbee.server.models import ActiveProfileResponse, ProfileEntryResponse 

192 

193 apply_overrides(data_dir=data_dir, use_global=use_global) 

194 if name is None: 

195 current = ActiveProfileResponse.from_active( 

196 run_or_fail(lambda: profiles.active(_store()), profiles.file_failure_message) 

197 ) 

198 emit(current, lambda: _render_active(current)) 

199 return 

200 entry = ProfileEntryResponse.from_entry( 

201 run_or_fail(lambda: profiles.show(_store(), name), profiles.file_failure_message) 

202 ) 

203 emit(entry, lambda: _render_entry(entry)) 

204 

205 

206@profile_app.command(name="list") 

207def profile_list( 

208 data_dir: Path | None = data_dir_option, 

209 use_global: bool = global_option, 

210) -> None: 

211 """List every profile, Default first, then other built-ins, then the rest; * marks this one.""" 

212 from lilbee.app import profiles 

213 from lilbee.server.models import ProfileEntryResponse, ProfileListResponse 

214 

215 apply_overrides(data_dir=data_dir, use_global=use_global) 

216 catalog = run_or_fail(lambda: profiles.list_profiles(_store()), profiles.file_failure_message) 

217 active_name = run_or_fail(lambda: profiles.active(_store()), profiles.file_failure_message).name 

218 listing = ProfileListResponse( 

219 profiles=[ProfileEntryResponse.from_entry(e) for e in catalog.entries] 

220 ) 

221 emit(listing, lambda: _render_list(listing.profiles, active_name)) 

222 

223 

224@profile_app.command(name="diff") 

225def profile_diff( 

226 name: str = _name_argument, 

227 data_dir: Path | None = data_dir_option, 

228 use_global: bool = global_option, 

229) -> None: 

230 """Show what applying a profile changes and which of your values it keeps.""" 

231 from lilbee.app import profiles 

232 from lilbee.server.models import ProfileDiffResponse 

233 

234 apply_overrides(data_dir=data_dir, use_global=use_global) 

235 diff = ProfileDiffResponse.from_diff( 

236 run_or_fail(lambda: profiles.diff(_store(), name), profiles.file_failure_message) 

237 ) 

238 emit(diff, lambda: _render_diff(diff)) 

239 

240 

241def _rebuild_json( 

242 result: ProfileApplyResponse | ProfileDiscardResponse, 

243 reindexed: int | None, 

244 reindex_error: str | None, 

245 reindex_failed: int, 

246) -> dict[str, Any]: 

247 payload = { 

248 **result.model_dump(mode="json"), 

249 "reindexed": reindexed, 

250 "reindex_failed": reindex_failed, 

251 } 

252 if reindex_error is not None: 

253 payload["reindex_error"] = reindex_error 

254 return payload 

255 

256 

257def _reindex() -> tuple[int | None, int, str | None]: 

258 """Rebuild the index; the count ingested, the count failed, and the error to report.""" 

259 from lilbee.cli.commands.ingest_sync import rebuild_failure_message, rebuild_or_raise 

260 

261 try: 

262 rebuilt = rebuild_or_raise() 

263 except RuntimeError as exc: 

264 return None, 0, str(exc) 

265 failure = rebuild_failure_message(rebuilt.failed) if rebuilt.failed else None 

266 return len(rebuilt.added), len(rebuilt.failed), failure 

267 

268 

269@profile_app.command(name="apply") 

270def profile_apply( 

271 name: str = _name_argument, 

272 reindex: bool = typer.Option( 

273 False, 

274 "--reindex", 

275 help=( 

276 "Rebuild the index afterwards when the profile sets a setting that needs it " 

277 "or a file is held out of the index." 

278 ), 

279 ), 

280 data_dir: Path | None = data_dir_option, 

281 use_global: bool = global_option, 

282) -> None: 

283 """Make a profile this project's; your own values and LILBEE_* variables stay.""" 

284 from lilbee.app import profiles 

285 from lilbee.server.models import ProfileApplyResponse 

286 

287 apply_overrides(data_dir=data_dir, use_global=use_global) 

288 result = ProfileApplyResponse.from_result( 

289 run_or_fail(lambda: profiles.apply(_store(), name), profiles.file_failure_message) 

290 ) 

291 if not cfg.json_mode: 

292 line(f"Applied {result.name}.", theme.ACCENT) 

293 render_changes(result.changes) 

294 if result.kept: 

295 line(f"Keeps your values of: {', '.join(result.kept)}") 

296 reindexed: int | None = None 

297 reindex_failed = 0 

298 reindex_error: str | None = None 

299 if reindex: 

300 # The store read below is the first services init of this command, and a 

301 # rebuild never needs the chat model that init would warm. 

302 cfg.worker_pool_eager_start = False 

303 if profiles.apply_needs_rebuild(_store(), result.name, result.reindex_required): 

304 reindexed, reindex_failed, reindex_error = _reindex() 

305 if cfg.json_mode: 

306 json_output(_rebuild_json(result, reindexed, reindex_error, reindex_failed)) 

307 else: 

308 _render_rebuild(result.reindex_required, reindexed, reindex_error) 

309 for warning in result.warnings: 

310 line(warning, theme.WARNING) 

311 if reindex_error is not None: 

312 raise typer.Exit(1) 

313 

314 

315@profile_app.command(name="new") 

316def profile_new( 

317 name: str = _new_name_argument, 

318 from_profile: str | None = typer.Option( 

319 None, "--from", help="Start from this profile's values." 

320 ), 

321 target: ProfileFolder = _target_option, 

322 data_dir: Path | None = data_dir_option, 

323 use_global: bool = global_option, 

324) -> None: 

325 """Write a profile file listing every profile setting, ready to edit.""" 

326 from lilbee.app import profiles 

327 from lilbee.server.models import ProfileLocationResponse 

328 

329 apply_overrides(data_dir=data_dir, use_global=use_global) 

330 location = ProfileLocationResponse.from_location( 

331 run_or_fail( 

332 lambda: profiles.new(_store(), name, target, from_name=from_profile), 

333 profiles.file_failure_message, 

334 ) 

335 ) 

336 emit(location, lambda: _render_location("Wrote", location)) 

337 

338 

339@profile_app.command(name="save") 

340def profile_save( 

341 name: str = _new_name_argument, 

342 target: ProfileFolder = _target_option, 

343 data_dir: Path | None = data_dir_option, 

344 use_global: bool = global_option, 

345) -> None: 

346 """Save this project's profile values plus yours as a new profile and switch to it.""" 

347 from lilbee.app import profiles 

348 from lilbee.server.models import ProfileSaveResponse 

349 

350 apply_overrides(data_dir=data_dir, use_global=use_global) 

351 result = ProfileSaveResponse.from_save( 

352 run_or_fail(lambda: profiles.save_as(name, target), profiles.file_failure_message) 

353 ) 

354 emit(result, lambda: _render_save("Saved", result)) 

355 

356 

357@profile_app.command(name="update") 

358def profile_update( 

359 data_dir: Path | None = data_dir_option, 

360 use_global: bool = global_option, 

361) -> None: 

362 """Write this project's profile values plus yours into the profile's own file.""" 

363 from lilbee.app import profiles 

364 from lilbee.server.models import ProfileSaveResponse 

365 

366 apply_overrides(data_dir=data_dir, use_global=use_global) 

367 result = ProfileSaveResponse.from_save( 

368 run_or_fail(lambda: profiles.update(_store()), profiles.file_failure_message) 

369 ) 

370 emit(result, lambda: _render_save("Updated", result)) 

371 

372 

373@profile_app.command(name="discard") 

374def profile_discard( 

375 reindex: bool = _reindex_option, 

376 data_dir: Path | None = data_dir_option, 

377 use_global: bool = global_option, 

378) -> None: 

379 """Remove your values of profile settings so the profile's values show through.""" 

380 from lilbee.app import profiles 

381 from lilbee.server.models import ProfileDiscardResponse 

382 

383 apply_overrides(data_dir=data_dir, use_global=use_global) 

384 result = ProfileDiscardResponse.from_result( 

385 run_or_fail(profiles.discard, profiles.file_failure_message) 

386 ) 

387 reindexed: int | None = None 

388 reindex_failed = 0 

389 reindex_error: str | None = None 

390 if reindex: 

391 # The store read below is the first services init of this command, and a 

392 # rebuild never needs the chat model that init would warm. 

393 cfg.worker_pool_eager_start = False 

394 if profiles.rebuild_reason(result.dropped, result.reindex_required) is not None: 

395 reindexed, reindex_failed, reindex_error = _reindex() 

396 if cfg.json_mode and reindex: 

397 json_output(_rebuild_json(result, reindexed, reindex_error, reindex_failed)) 

398 else: 

399 emit(result, lambda: _render_discard(result, reindexed, reindex_error)) 

400 if reindex_error is not None: 

401 raise typer.Exit(1) 

402 

403 

404@profile_app.command(name="duplicate") 

405def profile_duplicate( 

406 name: str = _name_argument, 

407 new_name: str = _new_name_argument, 

408 target: ProfileFolder = _target_option, 

409 data_dir: Path | None = data_dir_option, 

410 use_global: bool = global_option, 

411) -> None: 

412 """Copy a profile under a new name.""" 

413 from lilbee.app import profiles 

414 from lilbee.server.models import ProfileLocationResponse 

415 

416 apply_overrides(data_dir=data_dir, use_global=use_global) 

417 location = ProfileLocationResponse.from_location( 

418 run_or_fail( 

419 lambda: profiles.duplicate(_store(), name, new_name, target), 

420 profiles.file_failure_message, 

421 ) 

422 ) 

423 emit(location, lambda: _render_location("Wrote", location)) 

424 

425 

426@profile_app.command(name="rename") 

427def profile_rename( 

428 name: str = _name_argument, 

429 new_name: str = _new_name_argument, 

430 data_dir: Path | None = data_dir_option, 

431 use_global: bool = global_option, 

432) -> None: 

433 """Rename a project or global profile.""" 

434 from lilbee.app import profiles 

435 from lilbee.server.models import ProfileLocationResponse 

436 

437 apply_overrides(data_dir=data_dir, use_global=use_global) 

438 location = ProfileLocationResponse.from_location( 

439 run_or_fail( 

440 lambda: profiles.rename(_store(), name, new_name), profiles.file_failure_message 

441 ) 

442 ) 

443 emit(location, lambda: _render_location("Renamed to", location)) 

444 

445 

446@profile_app.command(name="delete") 

447def profile_delete( 

448 name: str = _name_argument, 

449 data_dir: Path | None = data_dir_option, 

450 use_global: bool = global_option, 

451) -> None: 

452 """Delete a project or global profile file; projects keep their recorded copy.""" 

453 from lilbee.app import profiles 

454 from lilbee.server.models import ProfileLocationResponse 

455 

456 apply_overrides(data_dir=data_dir, use_global=use_global) 

457 location = ProfileLocationResponse.from_location( 

458 run_or_fail(lambda: profiles.delete(_store(), name), profiles.file_failure_message) 

459 ) 

460 emit(location, lambda: _render_location("Deleted", location)) 

461 

462 

463@profile_app.command(name="export") 

464def profile_export( 

465 name: str = _name_argument, 

466 path: Path = _export_path_argument, 

467 overwrite: bool = typer.Option(False, "--overwrite", help="Replace an existing file."), 

468 data_dir: Path | None = data_dir_option, 

469 use_global: bool = global_option, 

470) -> None: 

471 """Write a profile to a file anyone can import.""" 

472 from lilbee.app import profiles 

473 from lilbee.server.models import ProfileLocationResponse 

474 

475 apply_overrides(data_dir=data_dir, use_global=use_global) 

476 location = ProfileLocationResponse.from_location( 

477 run_or_fail( 

478 lambda: profiles.export(_store(), name, path, overwrite=overwrite), 

479 profiles.file_failure_message, 

480 ) 

481 ) 

482 emit(location, lambda: _render_location("Exported", location)) 

483 

484 

485@profile_app.command(name="import") 

486def profile_import( 

487 file: Path = _file_argument, 

488 target: ProfileFolder = _target_option, 

489 overwrite: bool = typer.Option( 

490 False, "--overwrite", help="Replace a profile with the same name in the target folder." 

491 ), 

492 data_dir: Path | None = data_dir_option, 

493 use_global: bool = global_option, 

494) -> None: 

495 """Validate a profile file and copy it into a profile folder.""" 

496 from lilbee.app import profiles 

497 from lilbee.server.models import ProfileLocationResponse 

498 

499 apply_overrides(data_dir=data_dir, use_global=use_global) 

500 location = ProfileLocationResponse.from_location( 

501 run_or_fail( 

502 lambda: profiles.import_profile(_store(), file, target, overwrite=overwrite), 

503 profiles.file_failure_message, 

504 ) 

505 ) 

506 emit(location, lambda: _render_location("Imported", location)) 

507 

508 

509def _render_validation(result: ProfileValidationResponse) -> None: 

510 if result.valid: 

511 line(f"{result.name} is a valid profile.") 

512 return 

513 line(f"{result.name} is not a valid profile:", theme.ERROR) 

514 for problem in result.problems: 

515 line(f" {problem}") 

516 

517 

518@profile_app.command(name="validate") 

519def profile_validate( 

520 file: Path = _file_argument, 

521 folder: ProfileFolder = _folder_option, 

522 data_dir: Path | None = data_dir_option, 

523 use_global: bool = global_option, 

524) -> None: 

525 """Check a profile file and list every problem; exits 1 when there is one.""" 

526 from lilbee.app import profiles 

527 from lilbee.server.models import ProfileValidationResponse 

528 

529 apply_overrides(data_dir=data_dir, use_global=use_global) 

530 result = ProfileValidationResponse.from_validation(profiles.validate(file, folder)) 

531 emit(result, lambda: _render_validation(result)) 

532 if not result.valid: 

533 raise typer.Exit(1)