Enhance progress reporting in setup and deletion processes across multiple scripts

This commit is contained in:
Даниил Грабарь
2026-05-21 11:05:08 +10:00
parent 11f24172cb
commit 2903b8825d
7 changed files with 361 additions and 97 deletions
+20 -5
View File
@@ -94,15 +94,30 @@ def _remove_project_dir(write) -> None:
def _task_delete(screen: LogScreen, remove_ros: bool) -> None:
try:
_stop_docker_containers(screen.write)
_remove_docker_images(screen.write)
if remove_ros:
# stop(0-20) images(20-45) ros2(45-70) cobot(70-85) dir(85-100)
screen.set_progress(0, "Stopping containers...")
_stop_docker_containers(screen.write)
screen.set_progress(20, "Removing Docker images...")
_remove_docker_images(screen.write)
screen.set_progress(45, "Removing ROS2 Jazzy...")
_remove_ros2(screen.write)
screen.set_progress(70, "Uninstalling cobot CLI...")
_uninstall_cobot(screen.write)
screen.set_progress(85, "Removing project directory...")
_remove_project_dir(screen.write)
else:
# stop(0-25) images(25-60) cobot(60-85) dir(85-100)
screen.set_progress(0, "Stopping containers...")
_stop_docker_containers(screen.write)
screen.set_progress(25, "Removing Docker images...")
_remove_docker_images(screen.write)
screen.set_progress(60, "Uninstalling cobot CLI...")
_uninstall_cobot(screen.write)
screen.set_progress(85, "Removing project directory...")
_remove_project_dir(screen.write)
screen.set_progress(100, "Done")
screen.write("\n[green]Project fully removed.[/green]")
screen.finish(True)
@@ -143,7 +158,7 @@ class _DeleteApp(App[None]):
def _on_ros_choice(self, choice: Optional[str]) -> None:
remove_ros = choice is not None and choice.startswith("Yes")
self.push_screen(
LogScreen("Deleting project", lambda s: _task_delete(s, remove_ros)),
LogScreen("Deleting project", lambda s: _task_delete(s, remove_ros), show_progress=True),
lambda _: self.exit(),
)
+41 -16
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import argparse
import os
import re
import shutil
import subprocess
import sys
@@ -21,7 +22,6 @@ _DEFAULT_PORT = "8000"
Write = Callable[[str], None]
def _docker(*args: str, capture: bool = False) -> subprocess.CompletedProcess:
return subprocess.run(["docker", *args], capture_output=capture, text=True)
@@ -35,22 +35,31 @@ def _image_exists() -> bool:
return bool(_docker("images", "-q", _IMAGE_NAME, capture=True).stdout.strip())
def _build_docs_image(write: Write) -> bool:
def _build_docs_image(
write: Write,
on_progress: Optional[Callable[[float], None]] = None,
) -> bool:
write("[cyan][*][/cyan] Building documentation image (runs once)...")
env = {**os.environ, "DOCKER_BUILDKIT": "0"}
result = subprocess.run(
proc = subprocess.Popen(
["docker", "build", "-t", _IMAGE_NAME, str(_DOC_DIR)],
capture_output=True, text=True, env=env,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=env,
)
if result.returncode != 0:
for line in (result.stdout + result.stderr).splitlines():
if line.strip():
write(line)
write("[red]Image build failed.[/red]")
return False
for line in proc.stdout:
s = line.rstrip()
if s:
write(s)
if on_progress:
m = re.match(r"Step (\d+)/(\d+) :", line)
if m:
step, total = int(m.group(1)), int(m.group(2))
on_progress(step / total * 100)
proc.wait()
if proc.returncode == 0:
write("[green][ok][/green] Documentation image ready")
return True
write("[red]Image build failed.[/red]")
return False
def _task_up(screen: LogScreen, port: str) -> None:
@@ -67,12 +76,17 @@ def _task_up(screen: LogScreen, port: str) -> None:
return
if not _image_exists():
if not _build_docs_image(screen.write):
screen.set_progress(0, "Building documentation image...")
if not _build_docs_image(
screen.write,
on_progress=lambda p: screen.set_progress(p * 0.85, "Building documentation image..."),
):
screen.finish(False)
return
else:
screen.write("[dim]Documentation image already built, skipping.[/dim]")
screen.set_progress(88, "Starting MkDocs server...")
screen.write("\n[cyan][*][/cyan] Starting MkDocs server...")
result = _docker(
"run", "-d", "--name", _CONTAINER_NAME, "--rm",
@@ -86,6 +100,7 @@ def _task_up(screen: LogScreen, port: str) -> None:
screen.finish(False)
return
screen.set_progress(100, "Server running")
screen.write(f"\n[green]Docs running at:[/green] http://localhost:{port}")
screen.write(" Edit files in [bold]doc/lwc-doc/docs/[/bold] — reloads automatically.")
screen.write(" Stop with: [bold]cobot doc-setup down[/bold]")
@@ -102,8 +117,10 @@ def _task_down(screen: LogScreen) -> None:
screen.write("[yellow]Docs container is not running.[/yellow]")
screen.finish(True)
return
screen.set_progress(30, "Stopping container...")
screen.write("[cyan][*][/cyan] Stopping documentation server...")
_docker("stop", _CONTAINER_NAME)
screen.set_progress(100, "Done")
screen.write("[green][ok][/green] Container stopped.")
screen.finish(True)
except Exception as exc:
@@ -114,19 +131,26 @@ def _task_down(screen: LogScreen) -> None:
def _task_rebuild(screen: LogScreen, port: str) -> None:
try:
if _is_running():
screen.set_progress(5, "Stopping container...")
screen.write("[cyan][*][/cyan] Stopping existing container...")
_docker("stop", _CONTAINER_NAME)
screen.write("[green][ok][/green] Stopped.")
if _image_exists():
screen.set_progress(15, "Removing old image...")
screen.write("[cyan][*][/cyan] Removing old image...")
_docker("rmi", "-f", _IMAGE_NAME)
screen.write("[green][ok][/green] Image removed.")
if not _build_docs_image(screen.write):
screen.set_progress(20, "Building documentation image...")
if not _build_docs_image(
screen.write,
on_progress=lambda p: screen.set_progress(20 + p * 0.68, "Building documentation image..."),
):
screen.finish(False)
return
screen.set_progress(90, "Starting MkDocs server...")
screen.write("\n[cyan][*][/cyan] Starting MkDocs server...")
result = _docker(
"run", "-d", "--name", _CONTAINER_NAME, "--rm",
@@ -140,6 +164,7 @@ def _task_rebuild(screen: LogScreen, port: str) -> None:
screen.finish(False)
return
screen.set_progress(100, "Server running")
screen.write(f"\n[green]Docs running at:[/green] http://localhost:{port}")
screen.write(" Stop with: [bold]cobot doc-setup down[/bold]")
screen.finish(True)
@@ -159,7 +184,7 @@ class _DocApp(App[None]):
def on_mount(self) -> None:
if self._action == "down":
self.push_screen(
LogScreen("Documentation server", _task_down),
LogScreen("Documentation server", _task_down, show_progress=True),
lambda _: self.exit(),
)
elif self._action == "rebuild":
@@ -179,7 +204,7 @@ class _DocApp(App[None]):
return
p = (port.strip() or _DEFAULT_PORT) if port.isdigit() or not port.strip() else _DEFAULT_PORT
self.push_screen(
LogScreen("Documentation server", lambda s: _task_up(s, p)),
LogScreen("Documentation server", lambda s: _task_up(s, p), show_progress=True),
lambda _: self.exit(),
)
@@ -189,7 +214,7 @@ class _DocApp(App[None]):
return
p = (port.strip() or _DEFAULT_PORT) if port.isdigit() or not port.strip() else _DEFAULT_PORT
self.push_screen(
LogScreen("Documentation server — rebuild", lambda s: _task_rebuild(s, p)),
LogScreen("Documentation server — rebuild", lambda s: _task_rebuild(s, p), show_progress=True),
lambda _: self.exit(),
)
+78 -27
View File
@@ -44,21 +44,13 @@ class _Config:
hub_repo: str
def _run_quiet(cmd: List[str], write: Write, env=None) -> bool:
result = subprocess.run(
cmd, capture_output=True, text=True,
env=env or os.environ,
)
if result.returncode != 0:
for line in (result.stdout + result.stderr).splitlines():
if line.strip():
write(line)
return result.returncode == 0
def _build_image(
name: str, tag: str, dockerfile: Path, ctx: Path,
name: str,
tag: str,
dockerfile: Path,
ctx: Path,
write: Write,
on_progress: Optional[Callable[[float], None]] = None,
parent_tag: Optional[str] = None,
build_type: str = "release",
) -> bool:
@@ -71,34 +63,78 @@ def _build_image(
if parent_tag:
cmd += ["--build-arg", f"IMAGE={parent_tag}"]
cmd.append(str(ctx))
ok = _run_quiet(cmd, write, env)
if ok:
proc = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=env,
)
for line in proc.stdout:
s = line.rstrip()
if s:
write(s)
if on_progress:
m = re.match(r"Step (\d+)/(\d+) :", line)
if m:
step, total = int(m.group(1)), int(m.group(2))
on_progress(step / total * 100)
proc.wait()
if proc.returncode == 0:
write(f"[green][ok][/green] {name}")
else:
return True
write(f"[red]Build failed:[/red] {tag}")
return ok
return False
def _pull_image(name: str, tag: str, write: Write) -> bool:
def _pull_image(
name: str,
tag: str,
write: Write,
on_progress: Optional[Callable[[float], None]] = None,
) -> bool:
write(f"[cyan][*][/cyan] Pulling [bold]{name}[/bold] ({tag})...")
ok = _run_quiet(["docker", "pull", tag], write)
if ok:
if on_progress:
on_progress(5)
proc = subprocess.Popen(
["docker", "pull", tag],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
)
layers_total = 0
layers_done = 0
for line in proc.stdout:
s = line.rstrip()
if s:
write(s)
if "Pulling fs layer" in line or "Waiting" in line:
layers_total += 1
elif "Pull complete" in line or "Already exists" in line:
layers_done += 1
if on_progress and layers_total > 0:
on_progress(5 + layers_done / layers_total * 90)
proc.wait()
if proc.returncode == 0:
write(f"[green][ok][/green] {name}")
else:
if on_progress:
on_progress(100)
return True
write(f"[red]Pull failed:[/red] {tag}")
return ok
return False
def _task_execute(screen: LogScreen, cfg: _Config) -> None:
try:
chain = _WEBOTS_CHAIN if cfg.variant == "webots" else _CONTROLLER_CHAIN
n = len(chain)
if cfg.source == "build":
screen.write(
f"[bold]Building {len(chain)} image(s) — "
f"[bold]Building {n} image(s) — "
f"ROS {cfg.ros_version}{cfg.build_type}[/bold]\n"
)
for name in chain:
for i, name in enumerate(chain):
lo = i / n * 100
hi = (i + 1) / n * 100
screen.set_progress(lo, f"Image {i + 1}/{n}: building {name}...")
tag = f"{cfg.image_prefix}:{name}-{cfg.ros_version}"
dockerfile = _DOCKER_DIR / cfg.ros_version / name / "Dockerfile"
if not dockerfile.exists():
@@ -111,9 +147,19 @@ def _task_execute(screen: LogScreen, cfg: _Config) -> None:
f"{cfg.image_prefix}:{parent_name}-{cfg.ros_version}"
if parent_name else None
)
if not _build_image(name, tag, dockerfile, ctx, screen.write, parent_tag, cfg.build_type):
if not _build_image(
name, tag, dockerfile, ctx, screen.write,
on_progress=lambda p, lo=lo, hi=hi: screen.set_progress(
lo + p * (hi - lo) / 100, f"Image {i + 1}/{n}: building {name}..."
),
parent_tag=parent_tag,
build_type=cfg.build_type,
):
screen.finish(False)
return
screen.set_progress(hi)
screen.set_progress(100, "All images built")
screen.write(
f"\n[green]Done.[/green] "
f"Images tagged [bold]{cfg.image_prefix}:<name>-{cfg.ros_version}[/bold]."
@@ -127,9 +173,14 @@ def _task_execute(screen: LogScreen, cfg: _Config) -> None:
f"[bold]Pulling from {cfg.hub_repo}"
f"ROS {cfg.ros_version}{cfg.build_type}[/bold]\n"
)
if not _pull_image(short, full_ref, screen.write):
screen.set_progress(0, f"Pulling {full_ref}...")
if not _pull_image(
short, full_ref, screen.write,
on_progress=lambda p: screen.set_progress(p, f"Pulling {full_ref}..."),
):
screen.finish(False)
return
screen.set_progress(100, "Pull complete")
screen.write(f"\n[green]Done.[/green] Image ready: [bold]{full_ref}[/bold].")
screen.finish(True)
@@ -254,7 +305,7 @@ class _Wizard(App[None]):
else f"Pulling Docker image — ROS {cfg.ros_version}"
)
self.push_screen(
LogScreen(title, lambda screen: _task_execute(screen, cfg)),
LogScreen(title, lambda screen: _task_execute(screen, cfg), show_progress=True),
lambda _: self.exit(),
)
+136 -19
View File
@@ -5,6 +5,7 @@ import os
import shutil
import subprocess
import tempfile
import threading
import urllib.request
from pathlib import Path
from typing import Callable, List, Optional
@@ -72,6 +73,53 @@ def _run_logged(cmd: List[str], write: Write, env: dict | None = None, cwd=None)
raise RuntimeError(f"Command failed: {cmd[0]}")
def _run_apt_with_progress(
cmd: List[str],
write: Write,
on_progress: Callable[[float], None],
env: dict | None = None,
) -> None:
"""Run an apt command and feed real percentage from APT::Status-Fd to on_progress(0-100)."""
r_fd, w_fd = os.pipe()
try:
proc = subprocess.Popen(
cmd + [f"-o", f"APT::Status-Fd={w_fd}"],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
env=env or os.environ,
pass_fds=(w_fd,),
)
finally:
os.close(w_fd)
def _read_status() -> None:
with os.fdopen(r_fd, "r") as f:
for line in f:
# Format: dlstatus:N:PCT:MSG or pmstatus:NAME:PCT:MSG
parts = line.strip().split(":", 3)
if len(parts) >= 3:
try:
on_progress(float(parts[2]))
except ValueError:
pass
t = threading.Thread(target=_read_status, daemon=True)
t.start()
for line in proc.stdout:
s = line.rstrip()
if s:
write(s)
proc.wait()
t.join()
if proc.returncode != 0:
raise RuntimeError(f"Command failed: {cmd[0]}")
# ---------------------------------------------------------------------------
# Installation steps
# ---------------------------------------------------------------------------
def _setup_locale(write: Write) -> None:
write("[cyan][*][/cyan] Checking locale...")
if "UTF-8" in subprocess.run(["locale"], capture_output=True, text=True).stdout:
@@ -85,29 +133,37 @@ def _setup_locale(write: Write) -> None:
write("[green][ok][/green] Locale configured")
def _add_ros2_repo(write: Write) -> None:
def _add_ros2_repo(write: Write, on_progress: Optional[Callable[[float], None]] = None) -> None:
def _prog(p: float) -> None:
if on_progress:
on_progress(p)
write("[cyan][*][/cyan] Adding ROS2 apt repository...")
# Best-effort update before installing prereqs (ignore errors from broken repos)
# Best-effort update before installing prereqs
subprocess.run(["sudo", "apt-get", "update", "-qq"], capture_output=True)
_prog(15)
_run_quiet(
["sudo", "apt-get", "install", "-y", "--no-install-recommends",
"software-properties-common", "curl", "gnupg"],
write, _APT_ENV,
)
_prog(35)
_run_quiet(["sudo", "add-apt-repository", "-y", "universe"], write)
_prog(45)
# Always re-download and dearmor the key to fix any previous bad install
write("[cyan][*][/cyan] Downloading ROS2 signing key...")
with tempfile.NamedTemporaryFile(delete=False, suffix=".key") as tmp:
tmp_path = tmp.name
try:
urllib.request.urlretrieve(_ROS_KEY_URL, tmp_path)
_prog(60)
_run_quiet(["sudo", "gpg", "--dearmor", "--yes", "-o", str(_ROS_KEYRING), tmp_path])
finally:
os.unlink(tmp_path)
write("[green][ok][/green] Signing key installed")
_prog(65)
arch = subprocess.check_output(["dpkg", "--print-architecture"], text=True).strip()
codename = subprocess.check_output(
@@ -123,18 +179,26 @@ def _add_ros2_repo(write: Write) -> None:
)
if proc.returncode != 0:
raise RuntimeError(f"Failed to write {_ROS_SOURCES}")
_prog(70)
write("[cyan][*][/cyan] Updating apt cache...")
_run_quiet(["sudo", "apt-get", "update", "-q"], write, _APT_ENV)
_run_apt_with_progress(
["sudo", "apt-get", "update", "-q"],
write,
lambda p: _prog(70 + p * 0.30),
_APT_ENV,
)
write("[green][ok][/green] ROS2 repository ready")
_prog(100)
def _install_ros2_jazzy(write: Write) -> None:
def _install_ros2_jazzy(write: Write, on_progress: Optional[Callable[[float], None]] = None) -> None:
write("[cyan][*][/cyan] Installing ros-jazzy-desktop and ros-dev-tools...")
_run_quiet(
["sudo", "apt-get", "install", "-y",
"ros-jazzy-desktop", "ros-dev-tools"],
write, _APT_ENV,
_run_apt_with_progress(
["sudo", "apt-get", "install", "-y", "ros-jazzy-desktop", "ros-dev-tools"],
write,
on_progress or (lambda _: None),
_APT_ENV,
)
write("[green][ok][/green] ROS2 Jazzy Desktop installed")
@@ -164,14 +228,44 @@ def _setup_shell_rc(write: Write) -> None:
write(f"[green][ok][/green] Added ROS2 setup to ~/{rc.name}")
# ---------------------------------------------------------------------------
# Background tasks (run inside LogScreen worker)
# ---------------------------------------------------------------------------
def _task_install_jazzy(screen: LogScreen) -> None:
try:
screen.write("[bold]Installing ROS2 Jazzy[/bold]\n")
# Step 1 — locale (0 → 5 %)
screen.set_progress(0, "Setting up locale...")
screen.write("[bold]Step 1 / 5 — Locale[/bold]")
_setup_locale(screen.write)
_add_ros2_repo(screen.write)
_install_ros2_jazzy(screen.write)
# Step 2 — ROS2 repo (5 → 20 %)
screen.set_progress(5, "Adding ROS2 repository...")
screen.write("\n[bold]Step 2 / 5 — ROS2 repository[/bold]")
_add_ros2_repo(
screen.write,
on_progress=lambda p: screen.set_progress(5 + p * 0.15),
)
# Step 3 — ROS2 Jazzy (20 → 85 %)
screen.set_progress(20, "Installing ROS2 Jazzy Desktop...")
screen.write("\n[bold]Step 3 / 5 — ROS2 Jazzy Desktop[/bold]")
_install_ros2_jazzy(
screen.write,
on_progress=lambda p: screen.set_progress(20 + p * 0.65),
)
# Step 4 — colcon (85 → 92 %)
screen.set_progress(85, "Installing colcon...")
screen.write("\n[bold]Step 4 / 5 — colcon[/bold]")
_install_colcon(screen.write)
# Step 5 — shell rc (92 → 100 %)
screen.set_progress(92, "Configuring shell...")
screen.write("\n[bold]Step 5 / 5 — Shell configuration[/bold]")
_setup_shell_rc(screen.write)
screen.set_progress(100, "Done")
screen.write(
"\nRestart the terminal, then run [bold]cobot local-setup[/bold] again to build."
)
@@ -188,12 +282,27 @@ def _task_build(screen: LogScreen) -> None:
screen.write("Source ROS2 first: [bold]source /opt/ros/jazzy/setup.bash[/bold]")
screen.finish(False)
return
screen.write("[bold]Building project with colcon[/bold]\n")
_run_logged(
["colcon", "build", "--symlink-install"],
screen.write,
cwd=_PROJECT_DIR,
# Count packages so we can show X/total progress
list_result = subprocess.run(
["colcon", "list"], capture_output=True, text=True, cwd=_PROJECT_DIR,
)
total = max(len([l for l in list_result.stdout.splitlines() if l.strip()]), 1)
screen.write(f"[bold]Building {total} package(s) with colcon[/bold]\n")
screen.set_progress(0, f"0 / {total} packages done")
built = 0
def _track(line: str) -> None:
nonlocal built
screen.write(line)
if "Finished <<<" in line or "Failed <<<" in line:
built += 1
screen.set_progress(built / total * 100, f"{built} / {total} packages done")
_run_logged(["colcon", "build", "--symlink-install"], _track, cwd=_PROJECT_DIR)
screen.set_progress(100, "Build complete")
screen.write("\nActivate workspace: [bold]source install/setup.bash[/bold]")
screen.finish(True)
except Exception as exc:
@@ -201,6 +310,10 @@ def _task_build(screen: LogScreen) -> None:
screen.finish(False)
# ---------------------------------------------------------------------------
# Textual apps
# ---------------------------------------------------------------------------
class _InstallJazzyApp(App[None]):
CSS = SCREEN_CSS
@@ -220,7 +333,7 @@ class _InstallJazzyApp(App[None]):
self.exit()
return
self.push_screen(
LogScreen("Installing ROS2 Jazzy", _task_install_jazzy),
LogScreen("Installing ROS2 Jazzy", _task_install_jazzy, show_progress=True),
lambda _: self.exit(),
)
@@ -230,7 +343,7 @@ class _BuildApp(App[None]):
def on_mount(self) -> None:
self.push_screen(
LogScreen("Building project", _task_build),
LogScreen("Building project", _task_build, show_progress=True),
lambda _: self.exit(),
)
@@ -250,6 +363,10 @@ class _DockerPromptApp(App[bool]):
)
# ---------------------------------------------------------------------------
# CLI registration
# ---------------------------------------------------------------------------
def register(subparsers: argparse._SubParsersAction) -> None:
p = subparsers.add_parser(
"local-setup",
+42 -16
View File
@@ -1,5 +1,5 @@
import argparse
from typing import Optional
from typing import List, Optional
from textual.app import App
@@ -10,40 +10,66 @@ from cobot.commands.robot_setup import run as _robot_setup
from cobot.tui import SCREEN_CSS, PickScreen
class _AskSetupMode(App[Optional[str]]):
class _Ask(App[Optional[str]]):
"""Single-question picker that exits immediately with the chosen value."""
CSS = SCREEN_CSS
def __init__(self, step: str, question: str, options: List[str], default: str):
super().__init__()
self._step = step
self._question = question
self._options = options
self._default = default
def on_mount(self) -> None:
self.push_screen(
PickScreen(
"Step 1 of 1",
"How do you want to set up the project?",
[
"local-setup — install ROS2 Jazzy on this machine and build with colcon",
"docker-setup — build a Docker image with ROS2 Jazzy pre-installed",
],
"local-setup — install ROS2 Jazzy on this machine and build with colcon",
),
PickScreen(self._step, self._question, self._options, self._default),
self.exit,
)
def _ask(step: str, question: str, options: List[str], default: str) -> Optional[str]:
return _Ask(step, question, options, default).run()
def register(subparsers):
p = subparsers.add_parser("setup", help="First-time project setup")
p.set_defaults(func=run)
def run(args: argparse.Namespace) -> None:
# Step 1 — documentation
v = _ask("Step 1 of 3", "Set up the documentation server?", ["Yes", "No"], "Yes")
if v is None:
return
if v == "Yes":
_doc_setup(args)
choice = _AskSetupMode().run()
if choice is None:
# Step 2 — build environment
v = _ask(
"Step 2 of 3",
"How do you want to set up the build environment?",
[
"local-setup — install ROS2 Jazzy on this machine and build with colcon",
"docker-setup — build a Docker image with ROS2 Jazzy pre-installed",
],
"local-setup — install ROS2 Jazzy on this machine and build with colcon",
)
if v is None:
return
if choice.startswith("local"):
if v.startswith("local"):
_local_setup(args)
else:
_docker_setup(args)
# Step 3 — robot parameters
v = _ask(
"Step 3 of 3",
"Configure robot parameters (cobot-setting.yaml)?",
["Yes", "No"],
"Yes",
)
if v is None:
return
if v == "Yes":
_robot_setup(args)
+11 -4
View File
@@ -20,7 +20,8 @@ def _task_update(screen: LogScreen) -> None:
).strip()
screen.write(f"[cyan][*][/cyan] Branch: [bold]{branch}[/bold]")
# Fetch
# Fetch (0 → 30 %)
screen.set_progress(0, "Fetching from remote...")
screen.write("[cyan][*][/cyan] Fetching from remote...")
fetch = subprocess.run(
["git", "fetch", "origin"],
@@ -30,6 +31,7 @@ def _task_update(screen: LogScreen) -> None:
screen.write(f"[red]Fetch failed:[/red] {fetch.stderr.strip()}")
screen.finish(False)
return
screen.set_progress(30)
# Check how many commits behind
behind = subprocess.check_output(
@@ -38,6 +40,7 @@ def _task_update(screen: LogScreen) -> None:
).strip()
if behind == "0":
screen.set_progress(100, "Already up to date")
screen.write("[green][ok][/green] Already up to date.")
screen.finish(True)
return
@@ -51,7 +54,8 @@ def _task_update(screen: LogScreen) -> None:
for line in log_lines:
screen.write(f" [dim]{line}[/dim]")
# Pull
# Pull (30 → 80 %)
screen.set_progress(30, "Pulling changes...")
screen.write("\n[cyan][*][/cyan] Pulling changes...")
pull = subprocess.run(
["git", "pull", "origin", branch],
@@ -64,8 +68,10 @@ def _task_update(screen: LogScreen) -> None:
screen.write("[red]Pull failed.[/red]")
screen.finish(False)
return
screen.set_progress(80)
# Reinstall in case dependencies changed
# Reinstall (80 → 100 %)
screen.set_progress(80, "Reinstalling cobot CLI...")
screen.write("\n[cyan][*][/cyan] Reinstalling cobot CLI...")
reinstall = subprocess.run(
["uv", "tool", "install", "--editable", str(_PROJECT_DIR)],
@@ -76,6 +82,7 @@ def _task_update(screen: LogScreen) -> None:
else:
screen.write(f"[yellow]Warning:[/yellow] reinstall failed — {reinstall.stderr.strip()}")
screen.set_progress(100, "Done")
screen.write("\n[green]Project updated successfully.[/green]")
screen.finish(True)
@@ -89,7 +96,7 @@ class _UpdateApp(App[None]):
def on_mount(self) -> None:
self.push_screen(
LogScreen("Updating project", _task_update),
LogScreen("Updating project", _task_update, show_progress=True),
lambda _: self.exit(),
)
+25 -2
View File
@@ -6,7 +6,7 @@ from textual import on
from textual.app import ComposeResult
from textual.binding import Binding
from textual.screen import Screen
from textual.widgets import Footer, Input, LoadingIndicator, RadioButton, RadioSet, RichLog, Static
from textual.widgets import Footer, Input, LoadingIndicator, ProgressBar, RadioButton, RadioSet, RichLog, Static
SCREEN_CSS = """
Screen {
@@ -36,6 +36,15 @@ RadioSet {
Input {
margin-bottom: 1;
}
LogScreen #progress {
margin-top: 1;
height: 1;
}
LogScreen #step-label {
color: $text-muted;
text-style: dim;
margin-bottom: 1;
}
LogScreen #log {
height: 1fr;
border: none;
@@ -135,14 +144,18 @@ class LogScreen(Screen[bool]):
BINDINGS = [Binding("enter,escape", "close", "Close", show=False)]
def __init__(self, title: str, task: Callable[[LogScreen], None]):
def __init__(self, title: str, task: Callable[[LogScreen], None], show_progress: bool = False):
super().__init__()
self._title = title
self._run_fn = task
self._finished = False
self._show_progress = show_progress
def compose(self) -> ComposeResult:
yield Static(self._title, id="step")
if self._show_progress:
yield ProgressBar(id="progress", total=100, show_eta=False)
yield Static("", id="step-label")
yield RichLog(id="log", highlight=True, markup=True, wrap=True)
yield LoadingIndicator(id="loading")
yield Static("", id="hint")
@@ -152,6 +165,16 @@ class LogScreen(Screen[bool]):
self.query_one(RichLog).focus()
self.app.run_worker(lambda: self._run_fn(self), thread=True)
def set_progress(self, pct: float, label: str = "") -> None:
"""Thread-safe: update the progress bar and optional step label."""
if self._show_progress:
self.app.call_from_thread(self._do_set_progress, pct, label)
def _do_set_progress(self, pct: float, label: str) -> None:
self.query_one("#progress", ProgressBar).progress = pct
if label:
self.query_one("#step-label", Static).update(label)
def write(self, line: str) -> None:
"""Thread-safe: append a line to the log."""
self.app.call_from_thread(self._append, line)