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

146 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-05-15 20:55 +0000

1"""`lilbee model` sub-app: list/show/pull/rm/browse for installed models. 

2 

3Thin Typer wrapper around the surface-agnostic use-cases in 

4:mod:`lilbee.app.models`. Bare result models live in ``app.models``; 

5the Rich renderers below adapt them for human-readable terminal output. 

6""" 

7 

8from __future__ import annotations 

9 

10from pathlib import Path 

11from typing import TYPE_CHECKING 

12 

13import typer 

14from rich.console import Console 

15from rich.progress import BarColumn, Progress, TextColumn, TimeRemainingColumn 

16from rich.table import Table 

17 

18from lilbee.app.models import ( 

19 ListModelsResult, 

20 PullEvent, 

21 PullProgressEvent, 

22 PullResult, 

23 PullStatus, 

24 ShowModelResult, 

25 list_models_data, 

26 pull_model_data, 

27 remove_model_data, 

28 show_model_data, 

29) 

30from lilbee.cli import theme 

31from lilbee.cli.app import ( 

32 apply_overrides, 

33 console, 

34 data_dir_option, 

35 global_option, 

36) 

37from lilbee.cli.helpers import json_output 

38from lilbee.core.config import cfg 

39 

40if TYPE_CHECKING: 

41 from collections.abc import Callable 

42 

43 from lilbee.catalog import DownloadProgress 

44 from lilbee.catalog.types import ModelSource 

45 

46 

47def _render_list(data: ListModelsResult) -> Table: 

48 table = Table(title="Installed models") 

49 table.add_column("Name", style=theme.ACCENT) 

50 table.add_column("Source", style=theme.MUTED) 

51 table.add_column("Task") 

52 table.add_column("Size", justify="right") 

53 for entry in data.models: 

54 size = f"{entry.size_gb:.2f} GB" if entry.size_gb is not None else "" 

55 table.add_row(entry.name, entry.source, entry.task or "", size) 

56 return table 

57 

58 

59def _render_show(data: ShowModelResult) -> str: 

60 lines = [f"[{theme.ACCENT}]{data.model}[/{theme.ACCENT}]"] 

61 if data.catalog is not None: 

62 lines.extend( 

63 [ 

64 f" display_name: {data.catalog.display_name}", 

65 f" task: {data.catalog.task}", 

66 f" size_gb: {data.catalog.size_gb}", 

67 f" min_ram_gb: {data.catalog.min_ram_gb}", 

68 f" hf_repo: {data.catalog.hf_repo}", 

69 f" description: {data.catalog.description}", 

70 ] 

71 ) 

72 lines.append(f" installed: {data.installed}") 

73 if data.source: 

74 lines.append(f" source: {data.source}") 

75 if data.path: 

76 lines.append(f" path: {data.path}") 

77 if data.manifest is not None: 

78 lines.append(f" downloaded: {data.manifest.downloaded_at}") 

79 return "\n".join(lines) 

80 

81 

82model_app = typer.Typer( 

83 name="model", 

84 help="Manage installed and available models (pull / list / show / rm / browse).", 

85 no_args_is_help=True, 

86) 

87 

88_source_option = typer.Option( 

89 None, 

90 "--source", 

91 "-s", 

92 help="Filter by source: 'native' or 'remote' (default: all).", 

93) 

94_task_option = typer.Option( 

95 None, 

96 "--task", 

97 "-t", 

98 help="Filter by task: 'chat', 'embedding', 'vision', or 'rerank'.", 

99) 

100_yes_option = typer.Option( 

101 False, 

102 "--yes", 

103 "-y", 

104 help="Skip confirmation prompt.", 

105) 

106 

107 

108def _parse_source_or_bad_param(value: str | None) -> ModelSource | None: 

109 """Parse a CLI --source value, raising typer.BadParameter on bad input.""" 

110 from lilbee.catalog.types import ModelSource 

111 

112 try: 

113 return ModelSource.parse(value) 

114 except ValueError as exc: 

115 if cfg.json_mode: 

116 json_output({"error": str(exc)}) 

117 raise SystemExit(1) from None 

118 raise typer.BadParameter(str(exc)) from exc 

119 

120 

121@model_app.command("list") 

122def list_cmd( 

123 source: str | None = _source_option, 

124 task: str | None = _task_option, 

125 data_dir: Path | None = data_dir_option, 

126 use_global: bool = global_option, 

127) -> None: 

128 """List installed models across all sources.""" 

129 from lilbee.catalog.types import ModelTask 

130 

131 apply_overrides(data_dir=data_dir, use_global=use_global) 

132 try: 

133 parsed_task = ModelTask(task) if task else None 

134 except ValueError as exc: 

135 raise typer.BadParameter(str(exc)) from exc 

136 data = list_models_data(source=_parse_source_or_bad_param(source), task=parsed_task) 

137 if cfg.json_mode: 

138 json_output(data.model_dump()) 

139 return 

140 if not data.models: 

141 console.print("No models installed.") 

142 return 

143 console.print(_render_list(data)) 

144 

145 

146@model_app.command("show") 

147def show_cmd( 

148 ref: str = typer.Argument(..., help="Model ref (e.g. 'Qwen/Qwen3-0.6B-GGUF')."), 

149 data_dir: Path | None = data_dir_option, 

150 use_global: bool = global_option, 

151) -> None: 

152 """Show catalog and installed metadata for a model.""" 

153 from lilbee.modelhub.model_manager import ModelNotFoundError 

154 

155 apply_overrides(data_dir=data_dir, use_global=use_global) 

156 try: 

157 data = show_model_data(ref) 

158 except ModelNotFoundError as exc: 

159 if cfg.json_mode: 

160 json_output({"error": str(exc)}) 

161 else: 

162 console.print(f"[{theme.ERROR}]{exc}[/{theme.ERROR}]") 

163 raise typer.Exit(1) from None 

164 if cfg.json_mode: 

165 json_output(data.model_dump()) 

166 return 

167 console.print(_render_show(data)) 

168 

169 

170def _run_pull( 

171 ref: str, 

172 src: ModelSource, 

173 on_update: Callable[[DownloadProgress], None], 

174) -> PullResult: 

175 """Invoke ``pull_model_data`` and translate known errors to typer.Exit.""" 

176 try: 

177 return pull_model_data(ref, src, on_update=on_update) 

178 except (RuntimeError, PermissionError) as exc: 

179 if cfg.json_mode: 

180 json_output({"error": str(exc)}) 

181 else: 

182 console.print(f"[{theme.ERROR}]Error:[/{theme.ERROR}] {exc}") 

183 raise typer.Exit(1) from None 

184 

185 

186def _pull_json_stream(ref: str, src: ModelSource) -> None: 

187 """Emit newline-delimited JSON progress events, then the final result.""" 

188 

189 def on_update(p: DownloadProgress) -> None: 

190 event = PullProgressEvent( 

191 model=ref, percent=p.percent, detail=p.detail, cache_hit=p.is_cache_hit 

192 ) 

193 json_output(event.model_dump()) 

194 

195 final = _run_pull(ref, src, on_update) 

196 json_output({**final.model_dump(), "event": PullEvent.DONE.value}) 

197 

198 

199def _pull_interactive_progress(ref: str, src: ModelSource) -> None: 

200 """Drive Rich's Live progress bar during a native HuggingFace download.""" 

201 err_console = Console(stderr=True, force_terminal=True) 

202 with Progress( 

203 TextColumn("[progress.description]{task.description}"), 

204 BarColumn(), 

205 TextColumn("{task.percentage:>3.0f}%"), 

206 TextColumn("{task.fields[detail]}"), 

207 TimeRemainingColumn(), 

208 console=err_console, 

209 transient=False, 

210 ) as progress: 

211 task_id = progress.add_task(f"Downloading {ref}", total=100, detail="") 

212 

213 def on_update(p: DownloadProgress) -> None: 

214 progress.update(task_id, completed=p.percent, detail=p.detail) 

215 

216 final = _run_pull(ref, src, on_update) 

217 

218 if final.status == PullStatus.ALREADY_INSTALLED: 

219 console.print(f"{ref} is already installed.") 

220 else: 

221 console.print(f"Pulled [{theme.ACCENT}]{ref}[/{theme.ACCENT}].") 

222 

223 

224@model_app.command("pull") 

225def pull_cmd( 

226 ref: str = typer.Argument(..., help="Model ref to download (e.g. 'Qwen/Qwen3-0.6B-GGUF')."), 

227 source: str = typer.Option( 

228 "native", 

229 "--source", 

230 "-s", 

231 help="Pull from 'native' (HuggingFace GGUF) or 'remote' (SDK-managed).", 

232 ), 

233 data_dir: Path | None = data_dir_option, 

234 use_global: bool = global_option, 

235) -> None: 

236 """Download a model.""" 

237 from lilbee.catalog.types import ModelSource 

238 

239 apply_overrides(data_dir=data_dir, use_global=use_global) 

240 src = _parse_source_or_bad_param(source) or ModelSource.NATIVE 

241 if cfg.json_mode: 

242 _pull_json_stream(ref, src) 

243 else: 

244 _pull_interactive_progress(ref, src) 

245 

246 

247def _confirm_remove_or_exit(ref: str, yes: bool) -> None: 

248 if yes or cfg.json_mode: 

249 return 

250 if not typer.confirm(f"Remove {ref}?", default=False): 

251 console.print("Aborted.") 

252 raise typer.Exit(0) 

253 

254 

255@model_app.command("rm") 

256def rm_cmd( 

257 ref: str = typer.Argument(..., help="Model ref to remove."), 

258 source: str | None = _source_option, 

259 yes: bool = _yes_option, 

260 data_dir: Path | None = data_dir_option, 

261 use_global: bool = global_option, 

262) -> None: 

263 """Remove an installed model.""" 

264 apply_overrides(data_dir=data_dir, use_global=use_global) 

265 src = _parse_source_or_bad_param(source) 

266 _confirm_remove_or_exit(ref, yes) 

267 data = remove_model_data(ref, source=src) 

268 if cfg.json_mode: 

269 json_output(data.model_dump()) 

270 if not data.deleted: 

271 raise typer.Exit(1) 

272 return 

273 if not data.deleted: 

274 console.print(f"[{theme.WARNING}]Not found: {ref}[/{theme.WARNING}]") 

275 raise typer.Exit(1) 

276 suffix = f" ({data.freed_gb:.2f} GB freed)" if data.freed_gb else "" 

277 console.print(f"Removed [{theme.ACCENT}]{ref}[/{theme.ACCENT}]{suffix}.") 

278 

279 

280def _is_interactive_terminal() -> bool: 

281 """Return True when both stdin and stdout are connected to a TTY. 

282 

283 Extracted as a module-level helper so tests can patch it deterministically; 

284 CliRunner replaces ``sys.stdin`` during invoke which makes direct 

285 monkey-patching of ``sys.stdin.isatty`` unreliable. 

286 """ 

287 import sys 

288 

289 return sys.stdin.isatty() and sys.stdout.isatty() 

290 

291 

292@model_app.command("browse") 

293def browse_cmd( 

294 data_dir: Path | None = data_dir_option, 

295 use_global: bool = global_option, 

296) -> None: 

297 """Open the Textual TUI directly on the model catalog screen. 

298 

299 Exit codes follow the project convention: 2 for invalid flag 

300 combinations (``--json`` with an interactive-only command), 1 for 

301 runtime environment failures (no TTY). 

302 """ 

303 apply_overrides(data_dir=data_dir, use_global=use_global) 

304 if cfg.json_mode: 

305 json_output({"error": "model browse is interactive, not available in --json mode"}) 

306 raise typer.Exit(2) 

307 if not _is_interactive_terminal(): 

308 console.print(f"[{theme.ERROR}]Error:[/{theme.ERROR}] model browse requires a terminal.") 

309 raise typer.Exit(1) 

310 

311 from lilbee.cli.tui import run_tui 

312 

313 run_tui(initial_view="Catalog")