Coverage for src/lilbee/cli/tui/widgets/task_bar_controller.py: 100%
290 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"""TaskBarController and the per-task ProgressReporter."""
3from __future__ import annotations
5import asyncio
6import logging
7import threading
8import time
9from collections.abc import Callable, Collection, Iterator
10from contextlib import contextmanager
11from enum import StrEnum
12from typing import TYPE_CHECKING, Any
14from textual.app import App
16from lilbee.catalog.formatting import download_task_name
17from lilbee.cli.tui import messages as msg
18from lilbee.cli.tui.task_queue import CancelOrigin, Task, TaskQueue, TaskStatus, TaskType
19from lilbee.cli.tui.thread_safe import call_from_thread
20from lilbee.crawler import bootstrap_chromium, chromium_installed
21from lilbee.runtime import asyncio_loop
22from lilbee.runtime.cancellation import TaskCancelledError
23from lilbee.runtime.progress import EventType, SetupProgressEvent
25if TYPE_CHECKING:
26 from lilbee.catalog import CatalogModel
28log = logging.getLogger(__name__)
30# Each download runs in its own child process (catalog.download_process), so
31# transfers neither share a xet session nor die with a cancelled sibling.
32_DOWNLOAD_CONCURRENCY = 4
33_BYTES_PER_MB = 1024 * 1024
34# Seconds the running tasks get, together, to unwind and end their workers at app exit.
35_EXIT_STOP_BUDGET_S = 5.0
38class TaskOutcome(StrEnum):
39 """How a task terminated. Passed from worker thread to finalizer."""
41 DONE = "done"
42 FAILED = "failed"
43 CANCELLED = "cancelled"
46class ProgressReporter:
47 """Thread-safe handle a worker uses to report progress and check cancellation.
49 The worker only sees this object; it never touches ``self.app``,
50 ``call_from_thread``, or any screen. Writes to the lock-protected
51 ``TaskQueue`` so updates survive any UI navigation.
52 """
54 def __init__(self, controller: TaskBarController, task: Task) -> None:
55 self._controller = controller
56 # The row itself, not its id: a cancel marks this object, and a cleared history
57 # drops only the queue's reference to it, so the cancel still reaches the run.
58 self._task = task
60 @property
61 def task_id(self) -> str:
62 return self._task.task_id
64 def is_set(self) -> bool:
65 """True once this task is cancelled, by the user or at exit; the signal a run polls."""
66 return self._task.status is TaskStatus.CANCELLED
68 def cancelled_by_user(self) -> bool:
69 """True once the user cancelled this task; a stop at app exit is not a user cancel."""
70 return self._task.cancel_origin is CancelOrigin.USER
72 def check_cancelled(self) -> None:
73 """Raise ``TaskCancelledError`` if the task was cancelled, by the user or at exit."""
74 if self.is_set():
75 raise TaskCancelledError
77 def update(
78 self, progress: float, detail: str = "", *, indeterminate: bool | None = None
79 ) -> None:
80 """Write a progress snapshot to the shared queue.
82 Raises ``TaskCancelledError`` first if the UI cancelled the task, so
83 callers can use ``update`` as both a progress write and a cancel
84 checkpoint.
85 """
86 self.check_cancelled()
87 self._controller.queue.update_task(
88 self._task.task_id, progress, detail, indeterminate=indeterminate
89 )
92TaskTarget = Callable[[ProgressReporter], None]
95class _TaskWorker(threading.Thread):
96 """The worker thread of one task; it keeps the task's row for as long as it runs."""
98 def __init__(self, controller: TaskBarController, task: Task) -> None:
99 super().__init__(
100 target=controller._run_task_worker,
101 args=(task,),
102 daemon=True,
103 name=f"task-{task.task_id}",
104 )
105 self.task = task
108def _chromium_bootstrap_target(reporter: ProgressReporter) -> None:
109 """Worker target for the SETUP task: run bootstrap_chromium with progress forwarding.
111 Module-level so ``TaskBarController.ensure_chromium`` stays short and
112 tests can stub the target in isolation.
113 """
115 def _forward(event_type: EventType, data: Any) -> None:
116 if event_type != EventType.SETUP_PROGRESS:
117 return
118 if not isinstance(data, SetupProgressEvent):
119 return
120 total = data.total_bytes or 0
121 pct = int(data.downloaded_bytes * 100 / total) if total > 0 else 0
122 mb = data.downloaded_bytes // _BYTES_PER_MB
123 if total > 0:
124 detail = msg.SETUP_CHROMIUM_DETAIL.format(done=mb, total=total // _BYTES_PER_MB)
125 else:
126 detail = msg.SETUP_CHROMIUM_DETAIL_UNKNOWN.format(done=mb)
127 reporter.update(pct, detail)
129 asyncio_loop.run(bootstrap_chromium(on_progress=_forward))
132class TaskBarController:
133 """App-level owner of the shared TaskQueue + all long-running work.
135 The controller is attached as ``app.task_bar`` during
136 ``LilbeeApp.__init__``. All task lifecycle methods
137 (add/update/complete/fail/cancel) go through here so every ``TaskBar``
138 widget sees the same state, and every long-running op is spawned by
139 this controller: never by a screen that may dismiss mid-flight.
140 """
142 def __init__(self, app: App[Any]) -> None:
143 self.app = app
144 self.queue = TaskQueue(capacity={TaskType.DOWNLOAD.value: _DOWNLOAD_CONCURRENCY})
145 # task_id -> (target, on_success), from the hold that enqueues the row until its
146 # worker ends; a row cancelled with no worker loses its entry at the cancel.
147 self._task_targets: dict[str, tuple[TaskTarget, Callable[[], None] | None]] = {}
148 # task_id -> its started worker thread, joined by stop_all at exit.
149 self._workers: dict[str, _TaskWorker] = {}
150 # Held to enqueue a task with its target, to promote a row with the start of its
151 # worker, to start a detection, to begin the stop, and for every use of _workers.
152 self._lock = threading.Lock()
153 # Number of files in documents/ that are out of date with the store.
154 # Set by start_detect_pending; read by TaskBar to render the
155 # "N docs to sync · S to sync" hint when no live tasks are running.
156 # Atomic int writes are safe under the GIL; the bar polls at 10 Hz.
157 self.pending_sync_count: int = 0
158 self._detect_thread: threading.Thread | None = None
159 # Set once, by stop_all, in a lock hold; read only by _admission.
160 self._stopped = False
161 # Roles whose worker is currently in the spawn window (1-3 s cold
162 # start). Surfaced as a single TaskBar hint instead of one toast
163 # per role so the chat screen isn't drowned in implementation
164 # detail on first prompt.
165 self.spawning_roles: set[str] = set()
166 # Called on the main thread once at each end of a task's work.
167 self._work_end_listeners: list[Callable[[], None]] = []
169 def subscribe_work_end(self, listener: Callable[[], None]) -> None:
170 """Call *listener* on the main thread once at each end of a task's work.
172 The end is the exit of the task's worker, or the end of a row that has no worker.
174 A queue change cannot report this: a cancel ends the row, and the row's worker
175 ends later with no further change to the queue.
176 """
177 if listener not in self._work_end_listeners:
178 self._work_end_listeners.append(listener)
180 def unsubscribe_work_end(self, listener: Callable[[], None]) -> None:
181 """Stop calling *listener*; harmless if it is not subscribed."""
182 if listener in self._work_end_listeners:
183 self._work_end_listeners.remove(listener)
185 def _work_ended(self, task_id: str) -> None:
186 """Report the end of *task_id*'s work once: now, or when its worker exits if it has one."""
187 with self._lock:
188 if task_id in self._workers:
189 return
190 for listener in list(self._work_end_listeners):
191 try:
192 listener()
193 except Exception:
194 # A bad listener must not reach the caller of a cancel or starve the next one.
195 log.exception("A work-end listener raised")
197 def has_live_work(self, task_types: Collection[str]) -> bool:
198 """True while a task of *task_types* is queued, active, or unwinding after a cancel.
200 A cancel takes the row off the queue at once, but its worker keeps writing until
201 its next checkpoint, so a live worker counts as well as a live row.
202 """
203 # Queued first: a row promoted between the two reads then shows as active.
204 rows = self.queue.queued_tasks + self.queue.active_tasks
205 if any(task.task_type in task_types for task in rows):
206 return True
207 with self._lock:
208 workers = list(self._workers.values())
209 return any(w.is_alive() and w.task.task_type in task_types for w in workers)
211 def cancelling_tasks(self, task_types: Collection[str] | None = None) -> list[Task]:
212 """Cancelled tasks whose worker is alive, read from the map that has_live_work reads."""
213 with self._lock:
214 workers = list(self._workers.values())
215 return [
216 w.task
217 for w in workers
218 if w.is_alive()
219 and w.task.status is TaskStatus.CANCELLED
220 and (task_types is None or w.task.task_type in task_types)
221 ]
223 def add_task(
224 self,
225 name: str,
226 task_type: str,
227 fn: Callable[[], None] | None = None,
228 *,
229 indeterminate: bool = False,
230 ) -> str:
231 """Enqueue a task. Returns the new task_id."""
232 return self.queue.enqueue(
233 fn or (lambda: None), name, task_type, indeterminate=indeterminate
234 )
236 def update_task(
237 self,
238 task_id: str,
239 progress: float,
240 detail: str = "",
241 *,
242 indeterminate: bool | None = None,
243 ) -> None:
244 """Update progress and detail text for a task."""
245 self.queue.update_task(task_id, progress, detail, indeterminate=indeterminate)
247 def complete_task(self, task_id: str) -> None:
248 """Mark a task done. Row lingers in history until the user clears it."""
249 task_type = self._task_type_of(task_id)
250 self.queue.complete_task(task_id)
251 self._after_done_hooks(task_type)
252 self._advance_all(task_type)
253 self._work_ended(task_id)
255 def fail_task(self, task_id: str, detail: str = "") -> None:
256 """Mark a task failed. Row lingers in history until the user clears it."""
257 self.queue.fail_task(task_id, detail)
258 self._advance_all(self._task_type_of(task_id))
259 self._work_ended(task_id)
261 def cancel_task(self, task_id: str) -> None:
262 """Mark a task cancelled; its worker notices at the next cancel checkpoint.
264 A download worker polls the row's status and terminates its child
265 process, which stops the bytes; other workers raise out of their next
266 progress update.
267 """
268 started = task_id in self._task_targets
269 task_type = self._task_type_of(task_id)
270 self._cancel(task_id, CancelOrigin.USER)
271 if not started:
272 # Rows put straight on the queue have no worker whose exit advances
273 # it, so the cancel must. A started row either has a worker that
274 # finalizes and advances when it exits, or is queued and holds no
275 # slot worth freeing.
276 self._advance_all(task_type)
277 self._work_ended(task_id)
279 def _after_done_hooks(self, task_type: str | None) -> None:
280 """Side effects triggered by a DONE completion.
282 Callable from both the direct ``complete_task`` convenience and
283 the worker-thread ``_finalize_task`` path so every success route
284 stays in sync. Does NOT advance the queue; each caller picks the
285 advance strategy that fits its context (``_advance_all`` vs
286 ``_try_start_next``).
287 """
288 if task_type == TaskType.DOWNLOAD.value:
289 self._notify_model_installed()
290 elif task_type in (TaskType.SYNC.value, TaskType.WIKI.value):
291 self.reload_wiki_screens()
293 def _task_type_of(self, task_id: str) -> str | None:
294 task = self.queue.get_task(task_id)
295 return task.task_type if task else None
297 def _advance_all(self, task_type: str | None) -> None:
298 """Start the freed type's next task, then any other idle type's.
300 Promotion has to spawn the worker. A task advanced without one sits
301 ACTIVE with no thread behind it, which renders as a live row that never
302 progresses.
303 """
304 if task_type:
305 self._try_start_next(task_type)
306 self._try_start_next(None)
308 def downloading_label_for(self, ref: str) -> str | None:
309 """Return the task name if *ref*'s download is queued or active, else None.
311 ``ref`` is a model reference (catalog repo id or native GGUF
312 ref); the helper maps it to the canonical
313 :attr:`CatalogModel.display_name` and matches against
314 in-flight DOWNLOAD tasks. The returned label is suitable for
315 embedding in a user-facing toast.
316 """
317 label = download_task_name(ref)
318 if not label:
319 return None
320 for task in self.queue.active_tasks + self.queue.queued_tasks:
321 if task.task_type == TaskType.DOWNLOAD.value and task.name == label:
322 return task.name
323 return None
325 def set_pending_sync(self, count: int) -> None:
326 """Update the pending-sync count surfaced in the TaskBar hint."""
327 self.pending_sync_count = max(count, 0)
329 def clear_pending_sync(self) -> None:
330 """Drop the pending hint. Called when sync starts so the bar shows live progress instead."""
331 self.pending_sync_count = 0
333 def mark_role_spawning(self, role: str) -> None:
334 """Add *role* to the set of workers whose pool process is starting."""
335 self.spawning_roles.add(role)
337 def mark_role_spawned(self, role: str) -> None:
338 """Drop *role* from the spawn-in-progress set; harmless if already absent."""
339 self.spawning_roles.discard(role)
341 def start_detect_pending(self) -> None:
342 """Run the cheap sync-detection (filesystem walk + hash compare) on a daemon thread.
344 Writes the result via ``set_pending_sync``. No-op if a detect job
345 is already running or the exit stop began. Errors are logged and
346 silently swallowed: a failed detect just leaves the previous count
347 in place rather than blocking the UI.
348 """
349 thread = threading.Thread(
350 target=self._run_detect_pending, daemon=True, name="detect-pending"
351 )
352 with self._admission() as admitted:
353 running = self._detect_thread is not None and self._detect_thread.is_alive()
354 if admitted and not running:
355 self._detect_thread = thread
356 thread.start()
358 def _run_detect_pending(self) -> None:
359 # Local import: lilbee.data.ingest pulls in lancedb + the embedder
360 # transitively; the TUI shouldn't pay for that just to import the
361 # task bar widget.
362 from lilbee.data.ingest import detect_pending
364 try:
365 count = detect_pending()
366 except Exception:
367 log.warning("detect_pending failed", exc_info=True)
368 return
369 self.set_pending_sync(count)
371 def ensure_chromium(self, on_ready: Callable[[], None]) -> None:
372 """Kick off a Chromium bootstrap if missing, then call ``on_ready``.
374 If Chromium is already installed, ``on_ready`` runs immediately on
375 the caller's thread. Otherwise a single SETUP task is enqueued
376 that runs ``bootstrap_chromium``; on success the controller
377 invokes ``on_ready`` on the worker thread via the task's
378 ``on_success`` hook. On failure the SETUP task surfaces as FAILED
379 and ``on_ready`` is NOT called (the follow-up work shouldn't
380 proceed against a missing browser).
382 bb-wq8g: the on_ready hook is how callers like ``_do_crawl`` chain
383 their real work behind the one-time bootstrap.
384 """
385 if chromium_installed():
386 on_ready()
387 return
389 self.start_task(
390 msg.SETUP_CHROMIUM_NAME,
391 TaskType.SETUP,
392 _chromium_bootstrap_target,
393 indeterminate=False,
394 on_success=on_ready,
395 )
397 def start_task(
398 self,
399 name: str,
400 task_type: TaskType,
401 target: TaskTarget,
402 *,
403 indeterminate: bool = False,
404 on_success: Callable[[], None] | None = None,
405 dedupe_key: str | None = None,
406 ) -> str:
407 """Enqueue a task, spawn its worker, return task_id.
409 The *target* receives a ``ProgressReporter`` as its only argument.
410 It should periodically call ``reporter.update(percent, detail)`` and
411 may call ``reporter.check_cancelled()`` to cooperatively abort.
413 On success (target returns normally) the queue marks the task DONE
414 and ``on_success`` (if provided) runs after on the same worker
415 thread. On ``TaskCancelledError`` the task is marked CANCELLED. On any
416 other exception the task is marked FAILED with ``str(exc)`` as
417 detail. Rows linger in the Task Center under their final status
418 until the user presses capital ``C`` to clear; the bottom bar
419 flashes the outcome once and then hides when idle.
421 Per-type capacity in ``TaskQueue`` (1 for every type) controls
422 concurrency: a second task of the same type queues behind the first.
423 A task started once the exit stop began is cancelled as an exit.
424 """
425 # The row and its target enter in one hold, so no promotion sees one without the other.
426 with self._admission() as admitted:
427 task_id = self.queue.enqueue(
428 lambda: None,
429 name,
430 task_type.value,
431 indeterminate=indeterminate,
432 dedupe_key=dedupe_key,
433 )
434 if task_id in self._task_targets:
435 # Deduplicated: the live task keeps its original target.
436 return task_id
437 if admitted:
438 self._task_targets[task_id] = (target, on_success)
439 else:
440 self.queue.cancel(task_id, CancelOrigin.EXIT)
441 self._try_start_next(task_type.value)
442 return task_id
444 @contextmanager
445 def _admission(self) -> Iterator[bool]:
446 """Hold the lock to admit a task, a promotion or a detection; False once the stop began."""
447 with self._lock:
448 yield not self._stopped
450 def _try_start_next(self, task_type: str | None) -> None:
451 """Promote queued tasks into free slots: of *task_type*, or of every type for None."""
452 while self._promote_one(task_type):
453 pass
455 def _promote_one(self, task_type: str | None) -> bool:
456 """Promote one due task and start its worker in one admitted hold; False if none starts."""
457 with self._admission() as admitted:
458 # Once the exit stop began a row stays queued, and the stop cancels it.
459 if not admitted:
460 return False
461 task = self.queue.advance(task_type)
462 if task is None:
463 return False
464 # A row put straight on the queue has no target and gets no worker.
465 if task.task_id in self._task_targets:
466 worker = _TaskWorker(self, task)
467 # The map holds started threads only; a worker that ends at once waits here.
468 worker.start()
469 self._workers[task.task_id] = worker
470 return True
472 def _cancel(self, task_id: str, origin: CancelOrigin) -> None:
473 """Cancel the row; with no worker to drop its target, the target goes here."""
474 self.queue.cancel(task_id, origin)
475 with self._lock:
476 if task_id not in self._workers:
477 self._task_targets.pop(task_id, None)
479 def stop_all(self, budget_s: float = _EXIT_STOP_BUDGET_S) -> None:
480 """Cancel every task as an exit, unwind their coroutines on the loop, then join the workers.
482 Draining the loop cancels each task's pending await, so a sync waiting on
483 a model page runs its cleanup now, while the executors still accept work.
484 The drain and the joins spend from the one *budget_s*.
485 """
486 # Every start is whole before this hold or sees the stop after it.
487 with self._lock:
488 self._stopped = True
489 deadline = time.monotonic() + budget_s
490 for task in [*self.queue.queued_tasks, *self.queue.active_tasks]:
491 self._cancel(task.task_id, CancelOrigin.EXIT)
492 asyncio_loop.shutdown(budget_s)
493 with self._lock:
494 threads = [*self._workers.values(), self._detect_thread]
495 for thread in threads:
496 if thread is not None:
497 thread.join(max(0.0, deadline - time.monotonic()))
499 def _run_task_worker(self, task: Task) -> None:
500 """Body of the daemon worker thread."""
501 task_id = task.task_id
502 entry = self._task_targets.get(task_id)
503 if entry is None:
504 return
505 target, on_success = entry
506 task_type = task.task_type
507 reporter = ProgressReporter(self, task)
508 try:
509 target(reporter)
510 except (TaskCancelledError, asyncio.CancelledError) as exc:
511 log.info("Task %s cancelled", task_id)
512 self._post_finalize(task_id, TaskOutcome.CANCELLED, str(exc), task_type)
513 except Exception as exc:
514 log.warning("Task %s failed: %s", task_id, exc)
515 self._post_finalize(task_id, TaskOutcome.FAILED, str(exc), task_type)
516 else:
517 self._post_finalize(task_id, TaskOutcome.DONE, "", task_type)
518 if on_success is not None:
519 try:
520 on_success()
521 except Exception:
522 log.warning("on_success for %s raised", task_id, exc_info=True)
523 finally:
524 self._task_targets.pop(task_id, None)
525 with self._lock:
526 self._workers.pop(task_id, None)
527 # After the pop, so a listener that asks has_live_work sees this worker gone.
528 call_from_thread(self.app, self._work_ended, task_id)
530 def _post_finalize(
531 self, task_id: str, outcome: TaskOutcome, detail: str, task_type: str | None
532 ) -> None:
533 """Marshal finalization back to the main thread.
535 Main-thread execution matters because ``set_timer`` (used for the
536 flash-then-remove cycle) isn't safe from workers. ``call_from_thread``
537 targets ``self.app``: the App is long-lived; screens are not.
538 """
539 call_from_thread(self.app, self._finalize_task, task_id, outcome, detail, task_type)
541 def _finalize_task(
542 self, task_id: str, outcome: TaskOutcome, detail: str, task_type: str | None
543 ) -> None:
544 """Mark the queue state, refresh dependents, promote next queued task.
546 Runs on the main thread. Atomically: free the active slot, notify
547 anything downstream that needs a repaint (e.g. model dropdowns
548 after a download lands), and advance the queue. Rows stay in
549 history; the bottom bar flash expires on its own. Users clear
550 finished rows from the Task Center manually.
551 """
552 if outcome is TaskOutcome.DONE:
553 self.queue.complete_task(task_id)
554 self._after_done_hooks(task_type)
555 elif outcome is TaskOutcome.FAILED:
556 self.queue.fail_task(task_id, detail)
557 elif outcome is TaskOutcome.CANCELLED:
558 self.queue.cancel(task_id)
559 if detail:
560 self.queue.set_cancel_reason(task_id, detail)
561 if task_type:
562 self._try_start_next(task_type)
564 def _notify_model_installed(self) -> None:
565 """Refresh any ChatScreen's ModelBar so the new model is selectable.
567 The dropdowns are built once on mount from the registry; without
568 this nudge, a freshly-downloaded model only appears after the
569 user reopens the screen. A bar that is not mounted yet is no longer
570 this caller's problem: refresh_model_bar is a no-op until the bar
571 exists, and the bar scans on its own mount.
572 """
573 # Late import to avoid a circular (ChatScreen imports this module).
574 from lilbee.cli.tui.screens.chat import ChatScreen
576 for screen in self.app.screen_stack:
577 # screen_stack is typed Screen[Any]; narrow at runtime to
578 # locate the one screen that owns the ModelBar.
579 if isinstance(screen, ChatScreen):
580 screen.refresh_model_bar()
581 break
583 def reload_wiki_screens(self) -> None:
584 """Rescan open wiki and drafts screens after a task rewrote pages or drafts on disk."""
585 from textual.css.query import QueryError
587 from lilbee.cli.tui.screens.wiki import WikiScreen
588 from lilbee.cli.tui.screens.wiki_drafts import WikiDraftsScreen
590 for screen in self.app.screen_stack:
591 # screen_stack is typed Screen[Any]; narrow at runtime to the
592 # wiki views that expose a reload.
593 if isinstance(screen, WikiScreen | WikiDraftsScreen):
594 try:
595 screen.reload()
596 except QueryError:
597 log.debug("Wiki screen not mounted yet; skipping reload", exc_info=True)
599 def start_download(
600 self,
601 model: CatalogModel,
602 *,
603 allow_unsupported: bool = False,
604 on_success: Callable[[], None] | None = None,
605 ) -> str:
606 """Enqueue a download; ``on_success`` runs on the worker thread once the file is on disk.
608 Re-requesting a download that is already queued or running returns that
609 task's id and starts nothing.
610 """
611 return self.start_task(
612 model.display_name,
613 TaskType.DOWNLOAD,
614 lambda reporter: _download_target(reporter, model, allow_unsupported=allow_unsupported),
615 on_success=on_success,
616 dedupe_key=download_key(model),
617 )
619 def pending_download(self, model: CatalogModel) -> Task | None:
620 """Return the queued-or-active download for *model*, if there is one."""
621 return self.queue.find_pending(TaskType.DOWNLOAD.value, download_key(model))
624def download_key(model: CatalogModel) -> str:
625 """Identity of a pull: repo plus filename, since one repo ships several quants."""
626 return f"{model.hf_repo}::{model.gguf_filename}"
629def _download_target(
630 reporter: ProgressReporter, model: CatalogModel, *, allow_unsupported: bool = False
631) -> None:
632 """``start_task`` target for a HuggingFace model download.
634 Translates ``PermissionError`` into the gated-repo friendly message and
635 ``UnsupportedArchError`` into a user-facing arch-mismatch message so
636 every call site gets consistent error UX.
637 """
638 from lilbee.app.models import pull_model_data
639 from lilbee.catalog import DownloadProgress
640 from lilbee.catalog.compat import UnsupportedArchError
641 from lilbee.catalog.types import ModelSource
643 def _on_progress(p: DownloadProgress) -> None:
644 reporter.update(p.percent, f"{model.display_name}: {p.detail}")
646 try:
647 pull_model_data(
648 model.ref,
649 ModelSource.NATIVE,
650 on_update=_on_progress,
651 allow_unsupported=allow_unsupported,
652 cancel=reporter,
653 )
654 except PermissionError as exc:
655 raise RuntimeError(msg.CATALOG_GATED_REPO.format(name=model.display_name)) from exc
656 except UnsupportedArchError as exc:
657 raise RuntimeError(
658 f"Architecture {exc.architecture!r} not supported by this lilbee build."
659 ) from exc