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: def _task_delete(screen: LogScreen, remove_ros: bool) -> None:
try: try:
_stop_docker_containers(screen.write)
_remove_docker_images(screen.write)
if remove_ros: 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) _remove_ros2(screen.write)
screen.set_progress(70, "Uninstalling cobot CLI...")
_uninstall_cobot(screen.write) _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) _remove_project_dir(screen.write)
screen.set_progress(100, "Done")
screen.write("\n[green]Project fully removed.[/green]") screen.write("\n[green]Project fully removed.[/green]")
screen.finish(True) screen.finish(True)
@@ -143,7 +158,7 @@ class _DeleteApp(App[None]):
def _on_ros_choice(self, choice: Optional[str]) -> None: def _on_ros_choice(self, choice: Optional[str]) -> None:
remove_ros = choice is not None and choice.startswith("Yes") remove_ros = choice is not None and choice.startswith("Yes")
self.push_screen( 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(), lambda _: self.exit(),
) )
+41 -16
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import argparse import argparse
import os import os
import re
import shutil import shutil
import subprocess import subprocess
import sys import sys
@@ -21,7 +22,6 @@ _DEFAULT_PORT = "8000"
Write = Callable[[str], None] Write = Callable[[str], None]
def _docker(*args: str, capture: bool = False) -> subprocess.CompletedProcess: def _docker(*args: str, capture: bool = False) -> subprocess.CompletedProcess:
return subprocess.run(["docker", *args], capture_output=capture, text=True) 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()) 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)...") write("[cyan][*][/cyan] Building documentation image (runs once)...")
env = {**os.environ, "DOCKER_BUILDKIT": "0"} env = {**os.environ, "DOCKER_BUILDKIT": "0"}
result = subprocess.run( proc = subprocess.Popen(
["docker", "build", "-t", _IMAGE_NAME, str(_DOC_DIR)], ["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 proc.stdout:
for line in (result.stdout + result.stderr).splitlines(): s = line.rstrip()
if line.strip(): if s:
write(line) write(s)
write("[red]Image build failed.[/red]") if on_progress:
return False 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") write("[green][ok][/green] Documentation image ready")
return True return True
write("[red]Image build failed.[/red]")
return False
def _task_up(screen: LogScreen, port: str) -> None: def _task_up(screen: LogScreen, port: str) -> None:
@@ -67,12 +76,17 @@ def _task_up(screen: LogScreen, port: str) -> None:
return return
if not _image_exists(): 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) screen.finish(False)
return return
else: else:
screen.write("[dim]Documentation image already built, skipping.[/dim]") screen.write("[dim]Documentation image already built, skipping.[/dim]")
screen.set_progress(88, "Starting MkDocs server...")
screen.write("\n[cyan][*][/cyan] Starting MkDocs server...") screen.write("\n[cyan][*][/cyan] Starting MkDocs server...")
result = _docker( result = _docker(
"run", "-d", "--name", _CONTAINER_NAME, "--rm", "run", "-d", "--name", _CONTAINER_NAME, "--rm",
@@ -86,6 +100,7 @@ def _task_up(screen: LogScreen, port: str) -> None:
screen.finish(False) screen.finish(False)
return return
screen.set_progress(100, "Server running")
screen.write(f"\n[green]Docs running at:[/green] http://localhost:{port}") 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(" Edit files in [bold]doc/lwc-doc/docs/[/bold] — reloads automatically.")
screen.write(" Stop with: [bold]cobot doc-setup down[/bold]") 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.write("[yellow]Docs container is not running.[/yellow]")
screen.finish(True) screen.finish(True)
return return
screen.set_progress(30, "Stopping container...")
screen.write("[cyan][*][/cyan] Stopping documentation server...") screen.write("[cyan][*][/cyan] Stopping documentation server...")
_docker("stop", _CONTAINER_NAME) _docker("stop", _CONTAINER_NAME)
screen.set_progress(100, "Done")
screen.write("[green][ok][/green] Container stopped.") screen.write("[green][ok][/green] Container stopped.")
screen.finish(True) screen.finish(True)
except Exception as exc: except Exception as exc:
@@ -114,19 +131,26 @@ def _task_down(screen: LogScreen) -> None:
def _task_rebuild(screen: LogScreen, port: str) -> None: def _task_rebuild(screen: LogScreen, port: str) -> None:
try: try:
if _is_running(): if _is_running():
screen.set_progress(5, "Stopping container...")
screen.write("[cyan][*][/cyan] Stopping existing container...") screen.write("[cyan][*][/cyan] Stopping existing container...")
_docker("stop", _CONTAINER_NAME) _docker("stop", _CONTAINER_NAME)
screen.write("[green][ok][/green] Stopped.") screen.write("[green][ok][/green] Stopped.")
if _image_exists(): if _image_exists():
screen.set_progress(15, "Removing old image...")
screen.write("[cyan][*][/cyan] Removing old image...") screen.write("[cyan][*][/cyan] Removing old image...")
_docker("rmi", "-f", _IMAGE_NAME) _docker("rmi", "-f", _IMAGE_NAME)
screen.write("[green][ok][/green] Image removed.") 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) screen.finish(False)
return return
screen.set_progress(90, "Starting MkDocs server...")
screen.write("\n[cyan][*][/cyan] Starting MkDocs server...") screen.write("\n[cyan][*][/cyan] Starting MkDocs server...")
result = _docker( result = _docker(
"run", "-d", "--name", _CONTAINER_NAME, "--rm", "run", "-d", "--name", _CONTAINER_NAME, "--rm",
@@ -140,6 +164,7 @@ def _task_rebuild(screen: LogScreen, port: str) -> None:
screen.finish(False) screen.finish(False)
return return
screen.set_progress(100, "Server running")
screen.write(f"\n[green]Docs running at:[/green] http://localhost:{port}") screen.write(f"\n[green]Docs running at:[/green] http://localhost:{port}")
screen.write(" Stop with: [bold]cobot doc-setup down[/bold]") screen.write(" Stop with: [bold]cobot doc-setup down[/bold]")
screen.finish(True) screen.finish(True)
@@ -159,7 +184,7 @@ class _DocApp(App[None]):
def on_mount(self) -> None: def on_mount(self) -> None:
if self._action == "down": if self._action == "down":
self.push_screen( self.push_screen(
LogScreen("Documentation server", _task_down), LogScreen("Documentation server", _task_down, show_progress=True),
lambda _: self.exit(), lambda _: self.exit(),
) )
elif self._action == "rebuild": elif self._action == "rebuild":
@@ -179,7 +204,7 @@ class _DocApp(App[None]):
return return
p = (port.strip() or _DEFAULT_PORT) if port.isdigit() or not port.strip() else _DEFAULT_PORT p = (port.strip() or _DEFAULT_PORT) if port.isdigit() or not port.strip() else _DEFAULT_PORT
self.push_screen( 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(), lambda _: self.exit(),
) )
@@ -189,7 +214,7 @@ class _DocApp(App[None]):
return return
p = (port.strip() or _DEFAULT_PORT) if port.isdigit() or not port.strip() else _DEFAULT_PORT p = (port.strip() or _DEFAULT_PORT) if port.isdigit() or not port.strip() else _DEFAULT_PORT
self.push_screen( 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(), lambda _: self.exit(),
) )
+78 -27
View File
@@ -44,21 +44,13 @@ class _Config:
hub_repo: str 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( def _build_image(
name: str, tag: str, dockerfile: Path, ctx: Path, name: str,
tag: str,
dockerfile: Path,
ctx: Path,
write: Write, write: Write,
on_progress: Optional[Callable[[float], None]] = None,
parent_tag: Optional[str] = None, parent_tag: Optional[str] = None,
build_type: str = "release", build_type: str = "release",
) -> bool: ) -> bool:
@@ -71,34 +63,78 @@ def _build_image(
if parent_tag: if parent_tag:
cmd += ["--build-arg", f"IMAGE={parent_tag}"] cmd += ["--build-arg", f"IMAGE={parent_tag}"]
cmd.append(str(ctx)) 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}") write(f"[green][ok][/green] {name}")
else: return True
write(f"[red]Build failed:[/red] {tag}") 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})...") write(f"[cyan][*][/cyan] Pulling [bold]{name}[/bold] ({tag})...")
ok = _run_quiet(["docker", "pull", tag], write) if on_progress:
if ok: 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}") write(f"[green][ok][/green] {name}")
else: if on_progress:
on_progress(100)
return True
write(f"[red]Pull failed:[/red] {tag}") write(f"[red]Pull failed:[/red] {tag}")
return ok return False
def _task_execute(screen: LogScreen, cfg: _Config) -> None: def _task_execute(screen: LogScreen, cfg: _Config) -> None:
try: try:
chain = _WEBOTS_CHAIN if cfg.variant == "webots" else _CONTROLLER_CHAIN chain = _WEBOTS_CHAIN if cfg.variant == "webots" else _CONTROLLER_CHAIN
n = len(chain)
if cfg.source == "build": if cfg.source == "build":
screen.write( 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" 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}" tag = f"{cfg.image_prefix}:{name}-{cfg.ros_version}"
dockerfile = _DOCKER_DIR / cfg.ros_version / name / "Dockerfile" dockerfile = _DOCKER_DIR / cfg.ros_version / name / "Dockerfile"
if not dockerfile.exists(): 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}" f"{cfg.image_prefix}:{parent_name}-{cfg.ros_version}"
if parent_name else None 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) screen.finish(False)
return return
screen.set_progress(hi)
screen.set_progress(100, "All images built")
screen.write( screen.write(
f"\n[green]Done.[/green] " f"\n[green]Done.[/green] "
f"Images tagged [bold]{cfg.image_prefix}:<name>-{cfg.ros_version}[/bold]." 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"[bold]Pulling from {cfg.hub_repo}"
f"ROS {cfg.ros_version}{cfg.build_type}[/bold]\n" 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) screen.finish(False)
return return
screen.set_progress(100, "Pull complete")
screen.write(f"\n[green]Done.[/green] Image ready: [bold]{full_ref}[/bold].") screen.write(f"\n[green]Done.[/green] Image ready: [bold]{full_ref}[/bold].")
screen.finish(True) screen.finish(True)
@@ -254,7 +305,7 @@ class _Wizard(App[None]):
else f"Pulling Docker image — ROS {cfg.ros_version}" else f"Pulling Docker image — ROS {cfg.ros_version}"
) )
self.push_screen( 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(), lambda _: self.exit(),
) )
+136 -19
View File
@@ -5,6 +5,7 @@ import os
import shutil import shutil
import subprocess import subprocess
import tempfile import tempfile
import threading
import urllib.request import urllib.request
from pathlib import Path from pathlib import Path
from typing import Callable, List, Optional 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]}") 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: def _setup_locale(write: Write) -> None:
write("[cyan][*][/cyan] Checking locale...") write("[cyan][*][/cyan] Checking locale...")
if "UTF-8" in subprocess.run(["locale"], capture_output=True, text=True).stdout: 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") 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...") 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) subprocess.run(["sudo", "apt-get", "update", "-qq"], capture_output=True)
_prog(15)
_run_quiet( _run_quiet(
["sudo", "apt-get", "install", "-y", "--no-install-recommends", ["sudo", "apt-get", "install", "-y", "--no-install-recommends",
"software-properties-common", "curl", "gnupg"], "software-properties-common", "curl", "gnupg"],
write, _APT_ENV, write, _APT_ENV,
) )
_prog(35)
_run_quiet(["sudo", "add-apt-repository", "-y", "universe"], write) _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...") write("[cyan][*][/cyan] Downloading ROS2 signing key...")
with tempfile.NamedTemporaryFile(delete=False, suffix=".key") as tmp: with tempfile.NamedTemporaryFile(delete=False, suffix=".key") as tmp:
tmp_path = tmp.name tmp_path = tmp.name
try: try:
urllib.request.urlretrieve(_ROS_KEY_URL, tmp_path) urllib.request.urlretrieve(_ROS_KEY_URL, tmp_path)
_prog(60)
_run_quiet(["sudo", "gpg", "--dearmor", "--yes", "-o", str(_ROS_KEYRING), tmp_path]) _run_quiet(["sudo", "gpg", "--dearmor", "--yes", "-o", str(_ROS_KEYRING), tmp_path])
finally: finally:
os.unlink(tmp_path) os.unlink(tmp_path)
write("[green][ok][/green] Signing key installed") write("[green][ok][/green] Signing key installed")
_prog(65)
arch = subprocess.check_output(["dpkg", "--print-architecture"], text=True).strip() arch = subprocess.check_output(["dpkg", "--print-architecture"], text=True).strip()
codename = subprocess.check_output( codename = subprocess.check_output(
@@ -123,18 +179,26 @@ def _add_ros2_repo(write: Write) -> None:
) )
if proc.returncode != 0: if proc.returncode != 0:
raise RuntimeError(f"Failed to write {_ROS_SOURCES}") raise RuntimeError(f"Failed to write {_ROS_SOURCES}")
_prog(70)
write("[cyan][*][/cyan] Updating apt cache...") 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") 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...") write("[cyan][*][/cyan] Installing ros-jazzy-desktop and ros-dev-tools...")
_run_quiet( _run_apt_with_progress(
["sudo", "apt-get", "install", "-y", ["sudo", "apt-get", "install", "-y", "ros-jazzy-desktop", "ros-dev-tools"],
"ros-jazzy-desktop", "ros-dev-tools"], write,
write, _APT_ENV, on_progress or (lambda _: None),
_APT_ENV,
) )
write("[green][ok][/green] ROS2 Jazzy Desktop installed") 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}") write(f"[green][ok][/green] Added ROS2 setup to ~/{rc.name}")
# ---------------------------------------------------------------------------
# Background tasks (run inside LogScreen worker)
# ---------------------------------------------------------------------------
def _task_install_jazzy(screen: LogScreen) -> None: def _task_install_jazzy(screen: LogScreen) -> None:
try: 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) _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) _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) _setup_shell_rc(screen.write)
screen.set_progress(100, "Done")
screen.write( screen.write(
"\nRestart the terminal, then run [bold]cobot local-setup[/bold] again to build." "\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.write("Source ROS2 first: [bold]source /opt/ros/jazzy/setup.bash[/bold]")
screen.finish(False) screen.finish(False)
return return
screen.write("[bold]Building project with colcon[/bold]\n")
_run_logged( # Count packages so we can show X/total progress
["colcon", "build", "--symlink-install"], list_result = subprocess.run(
screen.write, ["colcon", "list"], capture_output=True, text=True, cwd=_PROJECT_DIR,
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.write("\nActivate workspace: [bold]source install/setup.bash[/bold]")
screen.finish(True) screen.finish(True)
except Exception as exc: except Exception as exc:
@@ -201,6 +310,10 @@ def _task_build(screen: LogScreen) -> None:
screen.finish(False) screen.finish(False)
# ---------------------------------------------------------------------------
# Textual apps
# ---------------------------------------------------------------------------
class _InstallJazzyApp(App[None]): class _InstallJazzyApp(App[None]):
CSS = SCREEN_CSS CSS = SCREEN_CSS
@@ -220,7 +333,7 @@ class _InstallJazzyApp(App[None]):
self.exit() self.exit()
return return
self.push_screen( self.push_screen(
LogScreen("Installing ROS2 Jazzy", _task_install_jazzy), LogScreen("Installing ROS2 Jazzy", _task_install_jazzy, show_progress=True),
lambda _: self.exit(), lambda _: self.exit(),
) )
@@ -230,7 +343,7 @@ class _BuildApp(App[None]):
def on_mount(self) -> None: def on_mount(self) -> None:
self.push_screen( self.push_screen(
LogScreen("Building project", _task_build), LogScreen("Building project", _task_build, show_progress=True),
lambda _: self.exit(), lambda _: self.exit(),
) )
@@ -250,6 +363,10 @@ class _DockerPromptApp(App[bool]):
) )
# ---------------------------------------------------------------------------
# CLI registration
# ---------------------------------------------------------------------------
def register(subparsers: argparse._SubParsersAction) -> None: def register(subparsers: argparse._SubParsersAction) -> None:
p = subparsers.add_parser( p = subparsers.add_parser(
"local-setup", "local-setup",
+42 -16
View File
@@ -1,5 +1,5 @@
import argparse import argparse
from typing import Optional from typing import List, Optional
from textual.app import App 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 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 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: def on_mount(self) -> None:
self.push_screen( self.push_screen(
PickScreen( PickScreen(self._step, self._question, self._options, self._default),
"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",
),
self.exit, 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): def register(subparsers):
p = subparsers.add_parser("setup", help="First-time project setup") p = subparsers.add_parser("setup", help="First-time project setup")
p.set_defaults(func=run) p.set_defaults(func=run)
def run(args: argparse.Namespace) -> None: 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) _doc_setup(args)
choice = _AskSetupMode().run() # Step 2 — build environment
if choice is None: 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 return
if v.startswith("local"):
if choice.startswith("local"):
_local_setup(args) _local_setup(args)
else: else:
_docker_setup(args) _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) _robot_setup(args)
+11 -4
View File
@@ -20,7 +20,8 @@ def _task_update(screen: LogScreen) -> None:
).strip() ).strip()
screen.write(f"[cyan][*][/cyan] Branch: [bold]{branch}[/bold]") 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...") screen.write("[cyan][*][/cyan] Fetching from remote...")
fetch = subprocess.run( fetch = subprocess.run(
["git", "fetch", "origin"], ["git", "fetch", "origin"],
@@ -30,6 +31,7 @@ def _task_update(screen: LogScreen) -> None:
screen.write(f"[red]Fetch failed:[/red] {fetch.stderr.strip()}") screen.write(f"[red]Fetch failed:[/red] {fetch.stderr.strip()}")
screen.finish(False) screen.finish(False)
return return
screen.set_progress(30)
# Check how many commits behind # Check how many commits behind
behind = subprocess.check_output( behind = subprocess.check_output(
@@ -38,6 +40,7 @@ def _task_update(screen: LogScreen) -> None:
).strip() ).strip()
if behind == "0": if behind == "0":
screen.set_progress(100, "Already up to date")
screen.write("[green][ok][/green] Already up to date.") screen.write("[green][ok][/green] Already up to date.")
screen.finish(True) screen.finish(True)
return return
@@ -51,7 +54,8 @@ def _task_update(screen: LogScreen) -> None:
for line in log_lines: for line in log_lines:
screen.write(f" [dim]{line}[/dim]") screen.write(f" [dim]{line}[/dim]")
# Pull # Pull (30 → 80 %)
screen.set_progress(30, "Pulling changes...")
screen.write("\n[cyan][*][/cyan] Pulling changes...") screen.write("\n[cyan][*][/cyan] Pulling changes...")
pull = subprocess.run( pull = subprocess.run(
["git", "pull", "origin", branch], ["git", "pull", "origin", branch],
@@ -64,8 +68,10 @@ def _task_update(screen: LogScreen) -> None:
screen.write("[red]Pull failed.[/red]") screen.write("[red]Pull failed.[/red]")
screen.finish(False) screen.finish(False)
return 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...") screen.write("\n[cyan][*][/cyan] Reinstalling cobot CLI...")
reinstall = subprocess.run( reinstall = subprocess.run(
["uv", "tool", "install", "--editable", str(_PROJECT_DIR)], ["uv", "tool", "install", "--editable", str(_PROJECT_DIR)],
@@ -76,6 +82,7 @@ def _task_update(screen: LogScreen) -> None:
else: else:
screen.write(f"[yellow]Warning:[/yellow] reinstall failed — {reinstall.stderr.strip()}") 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.write("\n[green]Project updated successfully.[/green]")
screen.finish(True) screen.finish(True)
@@ -89,7 +96,7 @@ class _UpdateApp(App[None]):
def on_mount(self) -> None: def on_mount(self) -> None:
self.push_screen( self.push_screen(
LogScreen("Updating project", _task_update), LogScreen("Updating project", _task_update, show_progress=True),
lambda _: self.exit(), lambda _: self.exit(),
) )
+25 -2
View File
@@ -6,7 +6,7 @@ from textual import on
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.binding import Binding from textual.binding import Binding
from textual.screen import Screen 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_CSS = """
Screen { Screen {
@@ -36,6 +36,15 @@ RadioSet {
Input { Input {
margin-bottom: 1; margin-bottom: 1;
} }
LogScreen #progress {
margin-top: 1;
height: 1;
}
LogScreen #step-label {
color: $text-muted;
text-style: dim;
margin-bottom: 1;
}
LogScreen #log { LogScreen #log {
height: 1fr; height: 1fr;
border: none; border: none;
@@ -135,14 +144,18 @@ class LogScreen(Screen[bool]):
BINDINGS = [Binding("enter,escape", "close", "Close", show=False)] 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__() super().__init__()
self._title = title self._title = title
self._run_fn = task self._run_fn = task
self._finished = False self._finished = False
self._show_progress = show_progress
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Static(self._title, id="step") 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 RichLog(id="log", highlight=True, markup=True, wrap=True)
yield LoadingIndicator(id="loading") yield LoadingIndicator(id="loading")
yield Static("", id="hint") yield Static("", id="hint")
@@ -152,6 +165,16 @@ class LogScreen(Screen[bool]):
self.query_one(RichLog).focus() self.query_one(RichLog).focus()
self.app.run_worker(lambda: self._run_fn(self), thread=True) 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: def write(self, line: str) -> None:
"""Thread-safe: append a line to the log.""" """Thread-safe: append a line to the log."""
self.app.call_from_thread(self._append, line) self.app.call_from_thread(self._append, line)