Enhance progress reporting in setup and deletion processes across multiple scripts
This commit is contained in:
@@ -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
@@ -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(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user