Coverage for src/lilbee/cli/commands/analyze.py: 100%
151 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"""The analyze command: read the corpus, recommend a profile, and the analyze tip."""
3from __future__ import annotations
5import asyncio
6from collections.abc import Iterator
7from contextlib import contextmanager
8from pathlib import Path
9from typing import TYPE_CHECKING
11import typer
12from rich.table import Table
13from rich.text import Text
15from lilbee.app.analyze import hide_tip, tip_state
16from lilbee.app.profiles import file_failure_message
17from lilbee.cli import theme
18from lilbee.cli.app import apply_overrides, console, data_dir_option, global_option
19from lilbee.cli.commands._shared import MAX_FAILURES_SHOWN, fail, line
20from lilbee.cli.commands.profile import render_changes
21from lilbee.cli.helpers import json_output
22from lilbee.core.config import cfg
23from lilbee.core.profile_files import ProfileFolder, ProfileStore
24from lilbee.runtime.cancellation import sigint_cancel
25from lilbee.runtime.console import PlainConsole
26from lilbee.runtime.progress import AnalyzeEvent, EventType
28if TYPE_CHECKING:
29 from lilbee.app.analyze import AnalyzeRequest
30 from lilbee.runtime.progress import DetailedProgressCallback, ProgressEvent
31 from lilbee.server.models import AnalyzeResponse
33READING_LABEL = "Reading files"
34CANCELLED_MESSAGE = "Analyze was cancelled; nothing was saved."
35OFF_ALONE_MESSAGE = "--off hides the tip and takes no folder, --apply, --save or --target."
36TIP_HIDDEN_MESSAGE = "The analyze tip is hidden for this project."
38_directory_argument = typer.Argument(
39 None,
40 metavar="[DIR]",
41 help="A folder to analyze; omit it to analyze the files lilbee indexes.",
42 show_default=False,
43)
44_apply_option = typer.Option(
45 False, "--apply", help="Save the recommended profile and switch this project to it."
46)
47_save_option = typer.Option(
48 None,
49 "--save",
50 metavar="NAME",
51 help="Save the recommended profile as NAME without switching to it.",
52)
53_target_option = typer.Option(
54 None,
55 "--target",
56 help="Where to save with --apply or --save: project (the default when there is one) or global.",
57 show_default=False,
58)
59_off_option = typer.Option(False, "--off", help="Hide the analyze tip; reads no files.")
62def print_tip_if_shown(root: Path) -> None:
63 """Print the analyze tip in text mode when the project at *root* should see it."""
64 from lilbee.app.analyze import TIP_TEXT, tip_shows
66 if not cfg.json_mode and tip_shows(root):
67 console.print(Text(TIP_TEXT, style=theme.MUTED), soft_wrap=True)
70@contextmanager
71def _progress() -> Iterator[DetailedProgressCallback]:
72 """A progress bar over the files analyze extracts; off in JSON mode."""
73 from rich.progress import BarColumn, MofNCompleteColumn, Progress
75 from lilbee.runtime.progress.columns import literal_text_column
77 with Progress(
78 literal_text_column("{task.description}"),
79 BarColumn(),
80 MofNCompleteColumn(),
81 transient=True,
82 console=PlainConsole(stderr=True),
83 disable=cfg.json_mode,
84 ) as progress:
85 task = progress.add_task(READING_LABEL, total=None)
87 def on_progress(event_type: EventType, data: ProgressEvent) -> None:
88 # the callback carries every event type; only analyze events move this bar
89 if event_type is EventType.ANALYZE and isinstance(data, AnalyzeEvent):
90 progress.update(task, completed=data.done, total=data.total)
92 yield on_progress
95def _reading_line(report: AnalyzeResponse) -> str:
96 read = f"Read {report.files_read} of {report.documents_total} documents"
97 if report.files_counted:
98 return f"{read}; counted {report.files_counted} code, image and archive files."
99 return f"{read}."
102def _render_reading(report: AnalyzeResponse) -> None:
103 line(_reading_line(report), theme.ACCENT)
104 if report.files_read + len(report.failed) < report.documents_total:
105 line(f"The documents are sampled evenly; analyze_max_files is {report.cap}.", theme.MUTED)
106 types = Table("File type", "Files")
107 for kind, count in report.file_types.items():
108 types.add_row(Text(kind), str(count))
109 console.print(types)
110 pdf = report.pdf
111 line(f"Code files: {report.code_share:.0%}")
112 line(f"Scanned pages: {pdf.scanned_pages} of {pdf.pages} PDF pages")
113 line(f"Scanned share, image files included: {pdf.scanned_share:.0%}")
114 line(f"PDFs with tables: {pdf.files_with_tables} of {pdf.files}")
115 if pdf.median_pages is not None:
116 line(f"Median PDF length: {pdf.median_pages:g} pages")
117 if report.median_chars is not None:
118 line(f"Median length of other files: {report.median_chars:g} characters")
121def _render_languages(report: AnalyzeResponse) -> None:
122 if not report.languages:
123 line("No language detected: no file has enough text.")
124 return
125 table = Table("Language", "Share", "Search stemmer", "Tesseract")
126 for lang in report.languages:
127 stemmer = lang.fts_language.value if lang.fts_language else "none"
128 tesseract = "installed" if lang.ocr_supported else "not installed"
129 table.add_row(lang.code, f"{lang.share:.0%}", stemmer, tesseract)
130 console.print(table)
133def _render_failures(report: AnalyzeResponse) -> None:
134 for folder in report.unreadable_folders:
135 line(f"Could not open folder: {folder}", theme.WARNING)
136 if not report.failed:
137 return
138 line(f"{len(report.failed)} files could not be read:", theme.WARNING)
139 for failure in report.failed[:MAX_FAILURES_SHOWN]:
140 line(f" {failure.file}: {failure.error}")
141 hidden = len(report.failed) - MAX_FAILURES_SHOWN
142 if hidden > 0:
143 line(f" and {hidden} more; --json lists them all")
146def _render_recommendation(report: AnalyzeResponse) -> None:
147 rec = report.recommendation
148 line(f"Recommended: {rec.name or rec.builtin}", theme.ACCENT)
149 for reason in rec.reasons:
150 line(f" {reason.key}: {reason.text}")
151 render_changes(rec.changes)
152 if rec.kept:
153 line(f"Keeps your values of: {', '.join(rec.kept)}")
154 for note in rec.notes:
155 line(note, theme.MUTED)
158def _render_saved(report: AnalyzeResponse) -> None:
159 saved = report.saved
160 if saved is not None and saved.applied:
161 line(f"This project now uses {saved.name}: {saved.path}", theme.ACCENT)
162 elif saved is not None:
163 line(f"Saved {saved.name}: {saved.path}")
164 elif report.recommendation.name is not None:
165 line("Run lilbee analyze --apply to save it and switch to it.", theme.MUTED)
166 if saved is not None:
167 for warning in saved.warnings:
168 line(warning, theme.WARNING)
171def _render(report: AnalyzeResponse) -> None:
172 _render_reading(report)
173 _render_languages(report)
174 _render_failures(report)
175 _render_recommendation(report)
176 _render_saved(report)
179def _analyze(request: AnalyzeRequest) -> AnalyzeResponse:
180 """Run analyze with a progress bar and Ctrl+C as a clean cancel; a refusal exits 1."""
181 from lilbee.app.analyze import run_analysis
182 from lilbee.runtime.cancellation import TaskCancelledError
183 from lilbee.server.models import AnalyzeResponse
185 try:
186 with _progress() as on_progress, sigint_cancel() as cancel:
187 report = asyncio.run(
188 run_analysis(ProfileStore(), request, on_progress=on_progress, cancel=cancel)
189 )
190 except TaskCancelledError:
191 fail(CANCELLED_MESSAGE)
192 except ValueError as exc:
193 fail(str(exc))
194 except OSError as exc:
195 fail(file_failure_message(exc))
196 return AnalyzeResponse.from_report(report)
199def _hide_tip() -> None:
200 from lilbee.server.models import AnalyzeStateResponse # CLI startup: 35 ms to import
202 try:
203 hide_tip(cfg.data_root)
204 except OSError as exc:
205 fail(file_failure_message(exc))
206 if cfg.json_mode:
207 json_output(
208 AnalyzeStateResponse.from_state(tip_state(cfg.data_root)).model_dump(mode="json")
209 )
210 else:
211 line(TIP_HIDDEN_MESSAGE)
214def analyze_cmd(
215 directory: Path | None = _directory_argument,
216 apply: bool = _apply_option,
217 save: str | None = _save_option,
218 target: ProfileFolder | None = _target_option,
219 off: bool = _off_option,
220 data_dir: Path | None = data_dir_option,
221 use_global: bool = global_option,
222) -> None:
223 """Read your documents and recommend a profile; --apply saves it and switches to it."""
224 from lilbee.app.analyze import AnalyzeRequest
226 apply_overrides(data_dir=data_dir, use_global=use_global)
227 if off:
228 if directory is not None or apply or save is not None or target is not None:
229 fail(OFF_ALONE_MESSAGE)
230 _hide_tip()
231 return
232 request = AnalyzeRequest(directory=directory, apply=apply, save=save, target=target)
233 response = _analyze(request)
234 if cfg.json_mode:
235 json_output(response.model_dump(mode="json"))
236 return
237 _render(response)