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
« 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."""
3from __future__ import annotations
5from pathlib import Path
6from typing import TYPE_CHECKING, Any
8import typer
9from rich.table import Table
10from rich.text import Text
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
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 )
33_CURRENT_DIR = Path()
35profile_app = typer.Typer(
36 help="Manage profiles: named sets of ingest, OCR, chunking and retrieval settings.",
37 no_args_is_help=True,
38)
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.")
61def _store() -> ProfileStore:
62 """The profile store."""
63 return ProfileStore()
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
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]
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)}")
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)
98def _render_active(current: ActiveProfileResponse) -> None:
99 from lilbee.app.profiles import status_note
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)
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)
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)
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)
147def _render_location(verb: str, location: ProfileLocationResponse) -> None:
148 line(f"{verb} {location.name}: {location.path}")
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)
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)
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)
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
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))
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
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))
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
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))
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
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
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
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
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)
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
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))
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
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))
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
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))
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
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)
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
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))
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
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))
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
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))
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
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))
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
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))
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}")
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
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)