feat: update version and dependencies in setup.py; add privilege management and process handling modules

- Updated version from 2026.05.31 to 2026.06.11 in setup.py
- Replaced 'textual' with 'rich' in install_requires
- Added privilege.py for managing sudo privileges with a keep-alive mechanism
- Introduced process.py for handling subprocesses with enhanced control and output streaming
- Created ui.py for unified console interactions and user prompts
This commit is contained in:
Даниил Грабарь
2026-06-11 12:21:02 +10:00
parent e76a07c8f6
commit ee047618cd
16 changed files with 1877 additions and 2578 deletions
+11 -2
View File
@@ -1,5 +1,4 @@
import argparse
import sys
# Import each command module so we can register its subparser.
# Импортируем каждый модуль команды, чтобы зарегистрировать его подпарсер.
@@ -96,7 +95,17 @@ def main():
_register_commands(subparsers)
args = parser.parse_args()
args.func(args)
# Install one SIGINT handler + atexit cleanup so a single Ctrl-C tears down
# any running subprocesses (builds, ros2 launch, docker) cleanly.
# Устанавливаем один обработчик SIGINT + очистку atexit, чтобы один Ctrl-C
# аккуратно завершал все запущенные подпроцессы (сборку, ros2 launch, docker).
from cobot import process, privilege
process.install_signal_handlers()
try:
args.func(args)
finally:
privilege.stop_keepalive()
def _register_commands(subparsers):
+28 -77
View File
@@ -3,11 +3,10 @@ from __future__ import annotations
import argparse
import shutil
from pathlib import Path
from typing import List, Optional
from typing import List
from textual.app import App
from cobot.tui import SCREEN_CSS, LogScreen, MultiPickScreen
from cobot import ui
from cobot.ui import header, done
_PROJECT_DIR = Path(__file__).parent.parent.parent
@@ -19,80 +18,21 @@ _DIR_MAP = {
}
def _task_clean(screen: LogScreen, dirs: List[str]) -> None:
"""Delete the selected top-level directories.
Удаляет выбранные директории верхнего уровня.
def _clean(dirs: List[str]) -> None:
"""Delete the selected top-level directories, printing the outcome of each.
Удаляет выбранные директории верхнего уровня, печатая результат по каждой.
"""
try:
screen.write("[bold]Cleaning build artifacts[/bold]\n")
total = len(dirs)
for i, label in enumerate(dirs):
if screen.is_stopped():
return
screen.set_progress(i / total * 100, f"Removing {label}...")
path = _DIR_MAP[label]
if path.exists():
shutil.rmtree(path)
screen.write(f"[green][ok][/green] Removed {label}")
else:
screen.write(f"[dim]Not found: {label}[/dim]")
if not screen.is_stopped():
screen.set_progress(100, "Done")
screen.write("\n[green]Done.[/green]")
screen.finish(True)
except Exception as exc:
if not screen.is_stopped():
screen.write(f"\n[red]Error:[/red] {exc}")
screen.finish(False)
class _CleanApp(App[None]):
"""Clean wizard: lets the user pick which directories to delete, then removes them.
Мастер очистки: позволяет выбрать директории для удаления, затем удаляет их.
"""
CSS = SCREEN_CSS
def __init__(self, all_dirs: bool):
super().__init__()
# True = skip the question and delete everything right away.
# True = пропустить вопрос и сразу удалить всё.
self._all_dirs = all_dirs
def on_mount(self) -> None:
if self._all_dirs:
self._start(_DIR_OPTIONS)
header("Очистка артефактов сборки")
removed = False
for label in dirs:
path = _DIR_MAP[label]
if path.exists():
shutil.rmtree(path)
ui.info(f" [green]✓[/green] Удалено {label}")
removed = True
else:
self._ask_dirs()
def _ask_dirs(self) -> None:
self.push_screen(
MultiPickScreen(
"clean",
"Which directories to delete?",
_DIR_OPTIONS,
note="Space — toggle · Enter — confirm",
),
self._got_dirs,
)
def _got_dirs(self, dirs: Optional[List[str]]) -> None:
if not dirs:
self.exit()
return
self._start(dirs)
def _start(self, dirs: List[str]) -> None:
self.push_screen(
LogScreen(
"Cleaning",
lambda s: _task_clean(s, dirs),
show_progress=True,
),
lambda _: self.exit(),
)
ui.info(f" [dim]Нет:[/dim] {label}")
done(True, "Очищено" if removed else "Нечего удалять")
def register(subparsers: argparse._SubParsersAction) -> None:
@@ -114,4 +54,15 @@ def run(args: argparse.Namespace) -> None:
"""Entry point for the clean command.
Точка входа для команды clean.
"""
_CleanApp(all_dirs=(getattr(args, "target", None) == "all")).run()
if getattr(args, "target", None) == "all":
_clean(_DIR_OPTIONS)
return
dirs = ui.multiselect(
"Какие директории удалить?",
_DIR_OPTIONS,
note="Space — отметить · Enter — подтвердить",
)
if not dirs:
return
_clean(dirs)
+104 -210
View File
@@ -4,110 +4,82 @@ import argparse
import shutil
import subprocess
from pathlib import Path
from typing import Optional
from typing import Callable
from textual.app import App
from cobot.tui import SCREEN_CSS, LogScreen, PickScreen
from cobot import ui
from cobot import privilege
from cobot.ui import done
from cobot.process import StepProgress
_PROJECT_DIR = Path(__file__).parent.parent.parent
Log = Callable[[str], None]
# Stop and remove all Docker containers whose name contains "lwc".
# Останавливаем и удаляем все Docker-контейнеры, чьё имя содержит "lwc".
def _stop_docker_containers(write) -> None:
def _stop_docker_containers(log: Log) -> None:
"""Stop and force-remove all Docker containers whose name contains "lwc".
Останавливает и принудительно удаляет все Docker-контейнеры с "lwc" в имени.
"""
write("[cyan][*][/cyan] Stopping Docker containers...")
log("[cyan][/cyan] Остановка Docker-контейнеров...")
result = subprocess.run(
["docker", "ps", "-a", "--filter", "name=lwc", "--format", "{{.Names}}"],
capture_output=True, text=True,
)
containers = [c for c in result.stdout.strip().splitlines() if c]
if not containers:
write("[dim]No project containers found.[/dim]")
log("[dim]Контейнеры проекта не найдены.[/dim]")
return
for name in containers:
subprocess.run(["docker", "rm", "-f", name], capture_output=True)
write(f"[green][ok][/green] Removed container: {name}")
log(f"[green][/green] Удалён контейнер: {name}")
# Remove all Docker images whose repository or tag contains "lwc".
# Удаляем все Docker-образы, репозиторий или тег которых содержит "lwc".
def _remove_docker_images(write) -> None:
def _remove_docker_images(log: Log) -> None:
"""Force-remove all local Docker images whose name or tag contains "lwc".
Принудительно удаляет все локальные Docker-образы с "lwc" в имени или теге.
"""
write("[cyan][*][/cyan] Removing Docker images...")
log("[cyan][/cyan] Удаление Docker-образов...")
result = subprocess.run(
["docker", "images", "--format", "{{.Repository}}:{{.Tag}}"],
capture_output=True, text=True,
)
project_images = [
img for img in result.stdout.strip().splitlines()
if "lwc" in img.lower()
]
project_images = [img for img in result.stdout.strip().splitlines() if "lwc" in img.lower()]
if not project_images:
write("[dim]No project images found.[/dim]")
log("[dim]Образы проекта не найдены.[/dim]")
return
for img in project_images:
subprocess.run(["docker", "rmi", "-f", img], capture_output=True)
write(f"[green][ok][/green] Removed image: {img}")
log(f"[green][/green] Удалён образ: {img}")
# Remove the Docker volume that stores the Webots asset cache.
# Удаляем Docker volume с кэшем ассетов Webots.
def _remove_webots_volume(write) -> None:
"""Remove the lwc-webots-cache Docker volume if it exists. Skips silently if absent.
Удаляет Docker volume lwc-webots-cache если он существует. Молча пропускает если отсутствует.
def _remove_webots_volume(log: Log) -> None:
"""Remove the lwc-webots-cache Docker volume if it exists.
Удаляет Docker volume lwc-webots-cache если он существует.
"""
result = subprocess.run(
["docker", "volume", "inspect", "lwc-webots-cache"],
capture_output=True,
)
result = subprocess.run(["docker", "volume", "inspect", "lwc-webots-cache"], capture_output=True)
if result.returncode != 0:
write("[dim]Webots cache volume not found, skipping.[/dim]")
log("[dim]Volume кэша Webots не найден, пропускаем.[/dim]")
return
subprocess.run(["docker", "volume", "rm", "lwc-webots-cache"], capture_output=True)
write("[green][ok][/green] Removed Docker volume: lwc-webots-cache")
log("[green][/green] Удалён Docker volume: lwc-webots-cache")
# Remove ROS2 Jazzy packages via apt and clean up the source line from shell configs.
# Uses the official removal commands to also unregister the ROS2 apt repository.
# Удаляем пакеты ROS2 Jazzy через apt и очищаем строку source из конфигов оболочки.
# Используем официальные команды удаления, которые также снимают регистрацию apt-репозитория ROS2.
def _remove_ros2(write) -> None:
"""Remove all ros-jazzy-* packages, the ros2-apt-source package, and the ROS2 source
line from .bashrc / .zshrc. Does nothing if /opt/ros/jazzy is not present.
Удаляет все пакеты ros-jazzy-*, пакет ros2-apt-source и строку source ROS2 из
.bashrc / .zshrc. Ничего не делает если /opt/ros/jazzy отсутствует.
def _remove_ros2(log: Log) -> None:
"""Remove all ros-jazzy-* packages, the ros2-apt-source, and the ROS2 source line.
Удаляет все пакеты ros-jazzy-*, ros2-apt-source и строку source ROS2 из конфигов.
"""
write("[cyan][*][/cyan] Removing ROS2 Jazzy packages...")
log("[cyan][/cyan] Удаление пакетов ROS2 Jazzy...")
if not Path("/opt/ros/jazzy").exists():
write("[dim]ROS2 Jazzy not found, skipping.[/dim]")
log("[dim]ROS2 Jazzy не найден, пропускаем.[/dim]")
else:
# Remove all ros-jazzy-* packages matched by the apt regex pattern ~n<name>.
# Удаляем все пакеты ros-jazzy-* по regex-паттерну apt ~n<имя>.
subprocess.run(
["sudo", "apt", "remove", "-y", "~nros-jazzy-*"],
capture_output=True,
)
subprocess.run(["sudo", "apt", "autoremove", "-y"], capture_output=True)
write("[green][ok][/green] ROS2 Jazzy packages removed")
subprocess.run(privilege.sudo(["apt", "remove", "-y", "~nros-jazzy-*"]), capture_output=True)
subprocess.run(privilege.sudo(["apt", "autoremove", "-y"]), capture_output=True)
log("[green]✓[/green] Пакеты ROS2 Jazzy удалены")
subprocess.run(privilege.sudo(["apt", "remove", "-y", "ros2-apt-source"]), capture_output=True)
subprocess.run(privilege.sudo(["apt", "update", "-qq"]), capture_output=True)
subprocess.run(privilege.sudo(["apt", "autoremove", "-y"]), capture_output=True)
log("[green]✓[/green] apt-репозиторий ROS2 удалён")
# Remove the ROS2 apt source package that added the repository.
# Удаляем пакет apt-источника ROS2, который добавил репозиторий.
subprocess.run(
["sudo", "apt", "remove", "-y", "ros2-apt-source"],
capture_output=True,
)
subprocess.run(["sudo", "apt", "update", "-qq"], capture_output=True)
subprocess.run(["sudo", "apt", "autoremove", "-y"], capture_output=True)
write("[green][ok][/green] ROS2 apt repository removed")
# Clean up the source line that local-setup added to the shell config.
# Очищаем строку source, добавленную local-setup в конфиг оболочки.
source_line = "source /opt/ros/jazzy/setup.bash"
for rc_name in [".bashrc", ".zshrc"]:
rc = Path.home() / rc_name
@@ -116,32 +88,24 @@ def _remove_ros2(write) -> None:
content = rc.read_text()
if source_line not in content:
continue
# Remove the whole block that was added by local-setup, not just the single line.
# Удаляем весь блок добавленный local-setup, а не только одну строку.
new_content = content.replace(f"\n# ROS2 Jazzy\n{source_line}\n", "\n")
new_content = new_content.replace(source_line, "")
rc.write_text(new_content)
write(f"[green][ok][/green] Cleaned up ~/{rc_name}")
log(f"[green][/green] Очищен ~/{rc_name}")
# Remove Webots from the system via apt.
# Удаляем Webots из системы через apt.
def _remove_webots(write) -> None:
"""Remove the webots package via apt, run autoremove, and clean up WEBOTS_HOME
from .bashrc / .zshrc. Skips if webots is not found on PATH.
Удаляет пакет webots через apt, запускает autoremove и очищает WEBOTS_HOME из
.bashrc / .zshrc. Пропускает если webots не найден в PATH.
def _remove_webots(log: Log) -> None:
"""Remove the webots package and clean WEBOTS_HOME from shell configs.
Удаляет пакет webots и очищает WEBOTS_HOME из конфигов оболочки.
"""
write("[cyan][*][/cyan] Removing Webots...")
log("[cyan][/cyan] Удаление Webots...")
if not shutil.which("webots"):
write("[dim]Webots not found, skipping.[/dim]")
log("[dim]Webots не найден, пропускаем.[/dim]")
return
subprocess.run(["sudo", "apt", "remove", "-y", "webots"], capture_output=True)
subprocess.run(["sudo", "apt", "autoremove", "-y"], capture_output=True)
write("[green][ok][/green] Webots removed")
subprocess.run(privilege.sudo(["apt", "remove", "-y", "webots"]), capture_output=True)
subprocess.run(privilege.sudo(["apt", "autoremove", "-y"]), capture_output=True)
log("[green][/green] Webots удалён")
# Remove the WEBOTS_HOME block that install_webots.sh added to shell configs.
# Удаляем блок WEBOTS_HOME, добавленный install_webots.sh в конфиги оболочки.
for rc_name in [".bashrc", ".zshrc"]:
rc = Path.home() / rc_name
if not rc.exists():
@@ -154,154 +118,65 @@ def _remove_webots(write) -> None:
new_content = new_content.replace("# Webots\n", "")
if new_content != content:
rc.write_text(new_content)
write(f"[green][ok][/green] Cleaned WEBOTS_HOME from ~/{rc_name}")
log(f"[green][/green] Очищен WEBOTS_HOME из ~/{rc_name}")
# Uninstall the cobot CLI from the uv tool store.
# Удаляем cobot CLI из хранилища инструментов uv.
def _uninstall_cobot(write) -> None:
def _uninstall_cobot(log: Log) -> None:
"""Uninstall the lightweight-cobot package from the uv tool store.
Удаляет пакет lightweight-cobot из хранилища инструментов uv.
"""
write("[cyan][*][/cyan] Uninstalling cobot CLI...")
result = subprocess.run(
["uv", "tool", "uninstall", "lightweight-cobot"],
capture_output=True, text=True,
)
log("[cyan][/cyan] Удаление cobot CLI...")
result = subprocess.run(["uv", "tool", "uninstall", "lightweight-cobot"],
capture_output=True, text=True)
if result.returncode == 0:
write("[green][ok][/green] cobot uninstalled")
log("[green][/green] cobot удалён")
else:
write(f"[yellow]Warning:[/yellow] {result.stderr.strip() or 'could not uninstall cobot'}")
log(f"[yellow]Предупреждение:[/yellow] {result.stderr.strip() or 'не удалось удалить cobot'}")
# Delete the entire project directory from disk.
# Удаляем всю директорию проекта с диска.
def _remove_project_dir(write) -> None:
"""Recursively delete the entire project directory (_PROJECT_DIR) from disk.
Рекурсивно удаляет всю директорию проекта (_PROJECT_DIR) с диска.
def _remove_project_dir(log: Log) -> None:
"""Recursively delete the entire project directory from disk.
Рекурсивно удаляет всю директорию проекта с диска.
"""
write(f"[cyan][*][/cyan] Removing project directory...")
try:
shutil.rmtree(_PROJECT_DIR)
write(f"[green][ok][/green] Removed {_PROJECT_DIR}")
except Exception as exc:
write(f"[red]Failed:[/red] {exc}")
raise
log("[cyan][/cyan] Удаление директории проекта...")
shutil.rmtree(_PROJECT_DIR)
log(f"[green]✓[/green] Удалено {_PROJECT_DIR}")
# Run all deletion steps in order.
# Progress ranges are split evenly across the active steps so the bar always reaches 100%.
# Выполняем все шаги удаления по порядку.
# Диапазоны прогресса делятся равномерно между активными шагами, чтобы бар всегда доходил до 100%.
def _task_delete(screen: LogScreen, remove_ros: bool, remove_webots: bool) -> None:
"""Worker function that runs inside LogScreen. Runs all deletion steps in order:
containers -> images -> ROS2 (optional) -> Webots (optional) -> cobot CLI -> project dir.
Рабочая функция внутри LogScreen. Выполняет все шаги удаления по порядку:
контейнеры -> образы -> ROS2 (опционально) -> Webots (опционально) -> cobot CLI -> директория.
def _delete(remove_ros: bool, remove_webots: bool) -> None:
"""Run all deletion steps in order, with progress split across the active steps.
Выполняет все шаги удаления по порядку, распределяя прогресс между активными шагами.
"""
try:
screen.set_progress(0, "Stopping containers...")
_stop_docker_containers(screen.write)
_remove_webots_volume(screen.write)
ok, fail_msg = True, ""
with StepProgress("Удаление проекта") as p:
try:
p.set(0, "Остановка контейнеров...")
_stop_docker_containers(p.raw)
_remove_webots_volume(p.raw)
screen.set_progress(20, "Removing Docker images...")
_remove_docker_images(screen.write)
p.set(20, "Удаление Docker-образов...")
_remove_docker_images(p.raw)
pct = 40
if remove_ros:
screen.set_progress(pct, "Removing ROS2 Jazzy...")
_remove_ros2(screen.write)
pct = 65
pct = 40
if remove_ros:
p.set(pct, "Удаление ROS2 Jazzy...")
_remove_ros2(p.raw)
pct = 65
if remove_webots:
p.set(pct, "Удаление Webots...")
_remove_webots(p.raw)
pct = 75
if remove_webots:
screen.set_progress(pct, "Removing Webots...")
_remove_webots(screen.write)
pct = 75
p.set(pct, "Удаление cobot CLI...")
_uninstall_cobot(p.raw)
screen.set_progress(pct, "Uninstalling cobot CLI...")
_uninstall_cobot(screen.write)
p.set(88, "Удаление директории проекта...")
_remove_project_dir(p.raw)
p.set(100, "Готово")
except Exception as exc:
ok, fail_msg = False, str(exc)
screen.set_progress(88, "Removing project directory...")
_remove_project_dir(screen.write)
if not screen.is_stopped():
screen.set_progress(100, "Done")
screen.write("\n[green]Project fully removed.[/green]")
screen.finish(True)
except Exception as exc:
if not screen.is_stopped():
screen.write(f"\n[red]Error:[/red] {exc}")
screen.finish(False)
# Multi-step confirmation wizard before anything is deleted.
# Shows extra questions only when the relevant software is actually installed.
# Многошаговый мастер подтверждения перед удалением.
# Дополнительные вопросы показываются только если соответствующее ПО действительно установлено.
class _DeleteApp(App[None]):
"""Deletion wizard that asks for confirmation, then optionally asks about ROS2 and Webots,
then launches LogScreen running _task_delete.
Мастер удаления: просит подтверждение, затем опционально спрашивает про ROS2 и Webots,
затем запускает LogScreen с _task_delete.
"""
CSS = SCREEN_CSS
def on_mount(self) -> None:
self.push_screen(
PickScreen(
"Confirm deletion",
"This will permanently delete the project, Docker images and containers. Are you sure?",
["No, cancel", "Yes, delete everything"],
"No, cancel",
),
self._on_confirm,
)
def _on_confirm(self, choice: Optional[str]) -> None:
if choice is None or choice.startswith("No"):
self.exit()
return
self.push_screen(
PickScreen(
"ROS2 Jazzy",
"Also remove ROS2 Jazzy from the system?",
["No, keep ROS2", "Yes, remove ROS2 Jazzy"],
"No, keep ROS2",
),
self._on_ros_choice,
)
def _on_ros_choice(self, choice: Optional[str]) -> None:
remove_ros = choice is not None and choice.startswith("Yes")
# Only ask about Webots if it is actually installed on this machine.
# Спрашиваем про Webots только если он действительно установлен на этой машине.
if shutil.which("webots"):
self.push_screen(
PickScreen(
"Webots",
"Also remove Webots from the system?",
["No, keep Webots", "Yes, remove Webots"],
"No, keep Webots",
),
lambda c: self._on_webots_choice(c, remove_ros),
)
else:
self._start_deletion(remove_ros, remove_webots=False)
def _on_webots_choice(self, choice: Optional[str], remove_ros: bool) -> None:
remove_webots = choice is not None and choice.startswith("Yes")
self._start_deletion(remove_ros, remove_webots)
def _start_deletion(self, remove_ros: bool, remove_webots: bool) -> None:
self.push_screen(
LogScreen(
"Deleting project",
lambda s: _task_delete(s, remove_ros, remove_webots),
show_progress=True,
),
lambda _: self.exit(),
)
done(ok, "Проект полностью удалён" if ok else fail_msg)
def register(subparsers: argparse._SubParsersAction) -> None:
@@ -313,4 +188,23 @@ def register(subparsers: argparse._SubParsersAction) -> None:
def run(args: argparse.Namespace) -> None:
_DeleteApp().run()
ui.header("Удаление проекта", "контейнеры, образы, опционально ROS2/Webots")
if not ui.confirm(
"Это безвозвратно удалит проект, Docker-образы и контейнеры. Продолжить?",
default=False,
):
return
remove_ros = ui.confirm("Также удалить ROS2 Jazzy из системы?", default=False)
remove_webots = False
if shutil.which("webots"):
remove_webots = ui.confirm("Также удалить Webots из системы?", default=False)
# apt removals need root — acquire sudo once before starting.
# Удаление через apt требует root — получаем sudo один раз перед началом.
if (remove_ros or remove_webots) and not privilege.ensure_sudo():
return
_delete(remove_ros, remove_webots)
+124 -248
View File
@@ -7,47 +7,38 @@ import shutil
import subprocess
import sys
from pathlib import Path
from typing import Callable, Optional
from textual.app import App
from cobot.tui import SCREEN_CSS, InputScreen, LogScreen
from cobot import process, ui
from cobot.ui import done
from cobot.process import StepProgress
_PROJECT_DIR = Path(__file__).parent.parent.parent
# The documentation source lives inside the project. We mount it into the container so
# MkDocs can pick up live edits without rebuilding the image.
# Исходники документации находятся внутри проекта. Монтируем директорию в контейнер, чтобы
# MkDocs мог подхватывать изменения вживую без пересборки образа.
# The documentation source lives inside the project; it is mounted into the container
# so MkDocs picks up live edits without rebuilding the image.
# Исходники документации находятся внутри проекта; директория монтируется в контейнер,
# чтобы MkDocs подхватывал изменения вживую без пересборки образа.
_DOC_DIR = _PROJECT_DIR / "doc" / "lwc-doc"
_IMAGE_NAME = "lwc-docs"
_CONTAINER_NAME = "lwc-docs"
_DEFAULT_PORT = "8000"
Write = Callable[[str], None]
# Thin wrapper around docker so we do not repeat ["docker", ...] everywhere.
# Тонкая обёртка вокруг docker, чтобы не повторять ["docker", ...] везде.
def _docker(*args: str, capture: bool = False) -> subprocess.CompletedProcess:
"""Run a docker subcommand. Pass capture=True to capture stdout/stderr instead of printing.
Запускает подкоманду docker. capture=True перехватывает stdout/stderr вместо вывода на экран.
"""Run a docker subcommand.
Запускает подкоманду docker.
"""
return subprocess.run(["docker", *args], capture_output=capture, text=True)
# Check whether the docs container is currently running.
# Проверяем, запущен ли сейчас контейнер с документацией.
def _is_running() -> bool:
"""Return True if the lwc-docs container is currently running.
Возвращает True если контейнер lwc-docs в данный момент запущен.
Возвращает True если контейнер lwc-docs запущен.
"""
r = _docker("ps", "--filter", f"name={_CONTAINER_NAME}", "--format", "{{.Names}}", capture=True)
return _CONTAINER_NAME in r.stdout
# Check whether the docs Docker image has already been built.
# Проверяем, был ли уже собран Docker-образ для документации.
def _image_exists() -> bool:
"""Return True if the lwc-docs Docker image exists locally.
Возвращает True если Docker-образ lwc-docs существует локально.
@@ -55,259 +46,132 @@ def _image_exists() -> bool:
return bool(_docker("images", "-q", _IMAGE_NAME, capture=True).stdout.strip())
# Build the MkDocs Docker image. Only needs to run once.
# Progress comes from parsing "Step X/Y" lines in the docker build output.
# Собираем Docker-образ MkDocs. Нужно сделать только один раз.
# Прогресс получаем, парся строки "Step X/Y" из вывода docker build.
def _build_docs_image(
write: Write,
on_progress: Optional[Callable[[float], None]] = None,
register_proc: Optional[Callable] = None,
) -> bool:
"""Build the lwc-docs Docker image from the doc/lwc-doc directory. Returns True on success.
Собирает Docker-образ lwc-docs из директории doc/lwc-doc. Возвращает True при успехе.
def _build_docs_image(p: StepProgress, lo: float, hi: float) -> bool:
"""Build the lwc-docs image, mapping "Step X/Y" to the lo..hi progress slice.
Собирает образ lwc-docs, отображая "Step X/Y" на участок lo..hi прогресса.
"""
write("[cyan][*][/cyan] Building documentation image (runs once)...")
# DOCKER_BUILDKIT=0 gives us "Step X/Y" lines that we can parse for progress.
# DOCKER_BUILDKIT=0 даёт нам строки "Step X/Y", которые можно парсить для прогресса.
p.raw("[cyan][/cyan] Сборка образа документации (один раз)...")
env = {**os.environ, "DOCKER_BUILDKIT": "0"}
proc = subprocess.Popen(
["docker", "build", "-t", _IMAGE_NAME, str(_DOC_DIR)],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=env,
)
if register_proc:
register_proc(proc)
for line in proc.stdout:
s = line.rstrip()
def on_line(s: str) -> None:
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 in (-9, -15):
p.log(s)
m = re.match(r"Step (\d+)/(\d+) :", s)
if m:
step, total = int(m.group(1)), int(m.group(2))
p.set(lo + step / total * (hi - lo), f"шаг {step}/{total}")
rc = process.stream(["docker", "build", "-t", _IMAGE_NAME, str(_DOC_DIR)],
env=env, on_line=on_line)
if rc != 0:
p.raw("[red]Сборка образа не удалась.[/red]")
return False
if proc.returncode == 0:
write("[green][ok][/green] Documentation image ready")
return True
write("[red]Image build failed.[/red]")
return False
p.raw("[green]✓[/green] Образ документации готов")
return True
# Start the docs server. Builds the image first if it does not exist yet.
# Запускаем сервер документации. Сначала собирает образ, если он ещё не существует.
def _task_up(screen: LogScreen, port: str) -> None:
"""Worker function for the "up" action. Builds the image if missing, then starts the container.
Рабочая функция для действия "up". Собирает образ если отсутствует, затем запускает контейнер.
def _start_container(p: StepProgress, port: str) -> bool:
"""Start the MkDocs container on the given port. Returns True on success.
Запускает контейнер MkDocs на заданном порту. Возвращает True при успехе.
"""
try:
if _is_running():
screen.write(f"[green]Docs already running at:[/green] http://localhost:{port}")
screen.write(" Stop with: [bold]cobot doc-setup down[/bold]")
if not screen.is_stopped():
screen.finish(True)
return
p.set(90, "Запуск сервера MkDocs...")
p.raw("[cyan]▸[/cyan] Запуск сервера MkDocs...")
result = _docker(
"run", "-d", "--name", _CONTAINER_NAME, "--rm",
"-p", f"{port}:8000",
"-v", f"{_DOC_DIR}:/docs",
_IMAGE_NAME, "serve", "--dev-addr=0.0.0.0:8000",
capture=True,
)
if result.returncode != 0:
p.raw(f"[red]Не удалось запустить контейнер.[/red]\n{result.stderr}")
return False
return True
if not _DOC_DIR.exists():
screen.write(f"[red]Doc directory not found:[/red] {_DOC_DIR}")
if not screen.is_stopped():
screen.finish(False)
return
def _task_up(port: str) -> None:
"""Build the image if missing, then start the docs container.
Собирает образ если отсутствует, затем запускает контейнер документации.
"""
if _is_running():
ui.info(f"[green]Документация уже запущена:[/green] http://localhost:{port}")
ui.note("Остановить: cobot doc-setup down")
return
if not _DOC_DIR.exists():
ui.error(f"Директория документации не найдена: {_DOC_DIR}")
return
ok, fail_msg = True, ""
with StepProgress("Сервер документации") as p:
if not _image_exists():
screen.set_progress(0, "Building documentation image...")
ok = _build_docs_image(
screen.write,
on_progress=lambda p: screen.set_progress(p * 0.85, "Building documentation image..."),
register_proc=screen.set_proc,
)
if screen.is_stopped():
return
if not ok:
screen.finish(False)
return
p.set(0, "Сборка образа документации...")
if not _build_docs_image(p, 0, 85):
ok, fail_msg = False, "Сборка образа не удалась"
else:
screen.write("[dim]Documentation image already built, skipping.[/dim]")
p.log("Образ документации уже собран, пропускаем.")
if ok and not _start_container(p, port):
ok, fail_msg = False, "Не удалось запустить контейнер"
if ok:
p.set(100, "Сервер запущен")
if screen.is_stopped():
return
screen.set_progress(88, "Starting MkDocs server...")
screen.write("\n[cyan][*][/cyan] Starting MkDocs server...")
result = _docker(
"run", "-d", "--name", _CONTAINER_NAME, "--rm",
"-p", f"{port}:8000",
# Mount the docs directory so edits appear live without restarting the container.
# Монтируем директорию с документацией, чтобы изменения появлялись сразу без перезапуска.
"-v", f"{_DOC_DIR}:/docs",
_IMAGE_NAME, "serve", "--dev-addr=0.0.0.0:8000",
capture=True,
)
if screen.is_stopped():
return
if result.returncode != 0:
screen.write(f"[red]Failed to start container.[/red]\n{result.stderr}")
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]")
if not screen.is_stopped():
screen.finish(True)
except Exception as exc:
if not screen.is_stopped():
screen.write(f"[red]Error:[/red] {exc}")
screen.finish(False)
if ok:
done(True, f"Документация доступна: http://localhost:{port}")
ui.note("Правьте файлы в doc/lwc-doc/docs/ — перезагрузка автоматическая.")
ui.note("Остановить: cobot doc-setup down")
else:
done(False, fail_msg)
# Stop the running docs container.
# Останавливаем работающий контейнер с документацией.
def _task_down(screen: LogScreen) -> None:
"""Worker function for the "down" action. Stops the lwc-docs container if it is running.
Рабочая функция для действия "down". Останавливает контейнер lwc-docs если он запущен.
def _task_down() -> None:
"""Stop the lwc-docs container if it is running.
Останавливает контейнер lwc-docs если он запущен.
"""
try:
if not _is_running():
screen.write("[yellow]Docs container is not running.[/yellow]")
if not screen.is_stopped():
screen.finish(True)
return
screen.set_progress(30, "Stopping container...")
screen.write("[cyan][*][/cyan] Stopping documentation server...")
if not _is_running():
ui.info("[yellow]Контейнер документации не запущен.[/yellow]")
return
with StepProgress("Сервер документации") as p:
p.set(30, "Остановка контейнера...")
p.raw("[cyan]▸[/cyan] Остановка сервера документации...")
_docker("stop", _CONTAINER_NAME)
if screen.is_stopped():
return
screen.set_progress(100, "Done")
screen.write("[green][ok][/green] Container stopped.")
screen.finish(True)
except Exception as exc:
if not screen.is_stopped():
screen.write(f"[red]Error:[/red] {exc}")
screen.finish(False)
p.set(100, "Готово")
done(True, "Контейнер остановлен")
# Stop the container, remove the old image, rebuild it, and start a new container.
# Останавливаем контейнер, удаляем старый образ, пересобираем и запускаем новый контейнер.
def _task_rebuild(screen: LogScreen, port: str) -> None:
"""Worker function for the "rebuild" action. Stops the container, removes the old image,
rebuilds it, and starts a fresh container on the given port.
Рабочая функция для действия "rebuild". Останавливает контейнер, удаляет старый образ,
пересобирает его и запускает новый контейнер на указанном порту.
def _task_rebuild(port: str) -> None:
"""Stop the container, remove the old image, rebuild it, and start a fresh container.
Останавливает контейнер, удаляет старый образ, пересобирает и запускает новый контейнер.
"""
try:
ok, fail_msg = True, ""
with StepProgress("Сервер документации — пересборка") as p:
if _is_running():
screen.set_progress(5, "Stopping container...")
screen.write("[cyan][*][/cyan] Stopping existing container...")
p.set(5, "Остановка контейнера...")
_docker("stop", _CONTAINER_NAME)
if screen.is_stopped():
return
screen.write("[green][ok][/green] Stopped.")
p.raw("[green]✓[/green] Остановлен.")
if _image_exists():
screen.set_progress(15, "Removing old image...")
screen.write("[cyan][*][/cyan] Removing old image...")
p.set(15, "Удаление старого образа...")
_docker("rmi", "-f", _IMAGE_NAME)
if screen.is_stopped():
return
screen.write("[green][ok][/green] Image removed.")
p.raw("[green]✓[/green] Образ удалён.")
p.set(20, "Сборка образа документации...")
if not _build_docs_image(p, 20, 88):
ok, fail_msg = False, "Сборка образа не удалась"
if ok and not _start_container(p, port):
ok, fail_msg = False, "Не удалось запустить контейнер"
if ok:
p.set(100, "Сервер запущен")
screen.set_progress(20, "Building documentation image...")
ok = _build_docs_image(
screen.write,
on_progress=lambda p: screen.set_progress(20 + p * 0.68, "Building documentation image..."),
register_proc=screen.set_proc,
)
if screen.is_stopped():
return
if not ok:
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",
"-p", f"{port}:8000",
"-v", f"{_DOC_DIR}:/docs",
_IMAGE_NAME, "serve", "--dev-addr=0.0.0.0:8000",
capture=True,
)
if screen.is_stopped():
return
if result.returncode != 0:
screen.write(f"[red]Failed to start container.[/red]\n{result.stderr}")
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]")
if not screen.is_stopped():
screen.finish(True)
except Exception as exc:
if not screen.is_stopped():
screen.write(f"[red]Error:[/red] {exc}")
screen.finish(False)
if ok:
done(True, f"Документация доступна: http://localhost:{port}")
ui.note("Остановить: cobot doc-setup down")
else:
done(False, fail_msg)
# One app handles all three actions (up/down/rebuild) by branching in on_mount.
# Одно приложение обрабатывает все три действия (up/down/rebuild), разветвляясь в on_mount.
class _DocApp(App[None]):
"""Documentation server app. Handles "up", "down", and "rebuild" actions by branching
in on_mount to the appropriate LogScreen task.
Приложение сервера документации. Обрабатывает действия "up", "down" и "rebuild",
разветвляясь в on_mount к соответствующей задаче LogScreen.
def _normalize_port(value: str) -> str:
"""Return a numeric port string, falling back to the default when invalid.
Возвращает числовой порт, откатываясь на значение по умолчанию при ошибке.
"""
CSS = SCREEN_CSS
def __init__(self, action: str):
super().__init__()
self._action = action
def on_mount(self) -> None:
if self._action == "down":
self.push_screen(
LogScreen("Documentation server", _task_down, show_progress=True),
lambda _: self.exit(),
)
elif self._action == "rebuild":
self.push_screen(
InputScreen("Step 1 of 1", "Port to serve documentation on:", _DEFAULT_PORT),
self._got_port_rebuild,
)
else:
self.push_screen(
InputScreen("Step 1 of 1", "Port to serve documentation on:", _DEFAULT_PORT),
self._got_port_up,
)
def _got_port_up(self, port: Optional[str]) -> None:
if port is None:
self.exit()
return
# Use the default port if the user cleared the input or typed something that is not a number.
# Используем порт по умолчанию если пользователь очистил ввод или написал не число.
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), show_progress=True),
lambda _: self.exit(),
)
def _got_port_rebuild(self, port: Optional[str]) -> None:
if port is None:
self.exit()
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), show_progress=True),
lambda _: self.exit(),
)
value = (value or "").strip()
return value if value.isdigit() else _DEFAULT_PORT
def register(subparsers: argparse._SubParsersAction) -> None:
@@ -324,9 +188,21 @@ def register(subparsers: argparse._SubParsersAction) -> None:
def run(args: argparse.Namespace) -> None:
if not shutil.which("docker"):
from rich.console import Console
Console().print("[red]Error:[/red] Docker is not installed or not on PATH.")
ui.error("Docker не установлен или отсутствует в PATH.")
sys.exit(1)
action = getattr(args, "action", "up")
_DocApp(action).run()
if action == "down":
_task_down()
return
port_v = ui.text("Порт для сервера документации:", _DEFAULT_PORT)
if port_v is None:
return
port = _normalize_port(port_v)
if action == "rebuild":
_task_rebuild(port)
else:
_task_up(port)
+126 -310
View File
@@ -4,15 +4,14 @@ import argparse
import os
import re
import shutil
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, List, Optional
from typing import List, Optional
from textual.app import App
from cobot.tui import SCREEN_CSS, InputScreen, LogScreen, PickScreen
from cobot import process, ui
from cobot.ui import done
from cobot.process import StepProgress
_PROJECT_DIR = Path(__file__).parent.parent.parent
_DOCKER_DIR = _PROJECT_DIR / "docker"
@@ -27,8 +26,8 @@ _DEFAULT_PREFIX = "lwc-local"
_CONTROLLER_CHAIN = ["ros-core", "ros-base", "ros-iiwa7"]
_WEBOTS_CHAIN = ["ros-core", "ros-base", "ros-iiwa7-webots"]
# Maps each image to the image it is built FROM. None means it starts from scratch (base Ubuntu).
# Сопоставляет каждый образ с тем, на основе которого он собирается. None - начинает с нуля (базовый Ubuntu).
# Maps each image to the image it is built FROM. None means it starts from base Ubuntu.
# Сопоставляет каждый образ с тем, на основе которого он собирается. None - базовый Ubuntu.
_IMAGE_PARENT: dict[str, str | None] = {
"ros-core": None,
"ros-base": "ros-core",
@@ -36,15 +35,11 @@ _IMAGE_PARENT: dict[str, str | None] = {
"ros-iiwa7-webots": "ros-base",
}
# These images need the full project source as Docker build context because they copy source files.
# Эти образы требуют полный исходный код проекта как контекст сборки, потому что копируют файлы.
# These images need the full project source as Docker build context.
# Эти образы требуют полный исходный код проекта как контекст сборки.
_NEEDS_PROJECT_CTX = {"ros-iiwa7", "ros-iiwa7-webots"}
Write = Callable[[str], None]
# All the choices the user makes in the wizard are stored here before we start the actual build.
# Все выборы пользователя в мастере хранятся здесь перед началом фактической сборки.
@dataclass
class _Config:
ros_version: str
@@ -55,29 +50,14 @@ class _Config:
hub_repo: str
# Build one Docker image and stream its output to the log.
# Progress is tracked by parsing "Step X/Y" lines that Docker prints during the build.
# Собирает один Docker-образ и транслирует его вывод в лог.
# Прогресс отслеживается по строкам "Step X/Y", которые Docker печатает во время сборки.
def _build_image(
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",
register_proc: Optional[Callable] = None,
) -> bool:
"""Build a single Docker image from a Dockerfile and stream its output line by line.
Returns True on success, False if the build failed or was cancelled.
Собирает один Docker-образ из Dockerfile и транслирует вывод построчно.
Возвращает True при успехе, False если сборка завершилась ошибкой или была отменена.
def _build_image(name: str, tag: str, dockerfile: Path, ctx: Path, p: StepProgress,
lo: float, hi: float, parent_tag: Optional[str], build_type: str) -> bool:
"""Build a single Docker image, streaming output and mapping "Step X/Y" to the
lo..hi slice of the progress bar. Returns True on success.
Собирает один Docker-образ, транслируя вывод и отображая "Step X/Y" на участок
lo..hi прогресс-бара. Возвращает True при успехе.
"""
write(f"[cyan][*][/cyan] Building [bold]{name}[/bold]...")
# DOCKER_BUILDKIT=0 gives us "Step X/Y" lines in the output which we parse for progress.
# DOCKER_BUILDKIT=0 даёт нам строки "Step X/Y" в выводе, которые мы парсим для прогресса.
p.raw(f"[cyan][/cyan] Сборка [bold]{name}[/bold]...")
env = {**os.environ, "DOCKER_BUILDKIT": "0"}
cmd = [
"docker", "build", "-t", tag, "-f", str(dockerfile),
@@ -87,315 +67,109 @@ def _build_image(
cmd += ["--build-arg", f"IMAGE={parent_tag}"]
cmd.append(str(ctx))
proc = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=env,
)
if register_proc:
register_proc(proc)
for line in proc.stdout:
s = line.rstrip()
def on_line(s: str) -> None:
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 in (-9, -15):
p.log(s)
m = re.match(r"Step (\d+)/(\d+) :", s)
if m:
step, total = int(m.group(1)), int(m.group(2))
p.set(lo + step / total * (hi - lo), f"{name}: шаг {step}/{total}")
rc = process.stream(cmd, env=env, on_line=on_line)
if rc in (-9, -15):
return False
if proc.returncode == 0:
write(f"[green][ok][/green] {name}")
if rc == 0:
p.raw(f"[green][/green] {name}")
return True
write(f"[red]Build failed:[/red] {tag}")
p.raw(f"[red]Сборка не удалась:[/red] {tag}")
return False
# Pull a Docker image from Hub and track progress by counting downloaded layers.
# Скачиваем Docker-образ с Hub и отслеживаем прогресс по количеством скачанных слоёв.
def _pull_image(
name: str,
tag: str,
write: Write,
on_progress: Optional[Callable[[float], None]] = None,
register_proc: Optional[Callable] = None,
) -> bool:
"""Pull a Docker image from Docker Hub and report layer-by-layer progress.
Returns True on success, False if the pull failed or was cancelled.
Скачивает Docker-образ с Docker Hub и сообщает о прогрессе по слоям.
Возвращает True при успехе, False если скачивание завершилось ошибкой или было отменено.
def _pull_image(name: str, tag: str, p: StepProgress, lo: float, hi: float) -> bool:
"""Pull a Docker image, tracking progress by counting completed layers.
Скачивает Docker-образ, отслеживая прогресс по числу завершённых слоёв.
"""
write(f"[cyan][*][/cyan] Pulling [bold]{name}[/bold] ({tag})...")
if on_progress:
on_progress(5)
proc = subprocess.Popen(
["docker", "pull", tag],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
)
if register_proc:
register_proc(proc)
p.raw(f"[cyan][/cyan] Скачивание [bold]{name}[/bold] ({tag})...")
layers_total = 0
layers_done = 0
for line in proc.stdout:
s = line.rstrip()
def on_line(s: str) -> None:
nonlocal layers_total, layers_done
if s:
write(s)
# Count layers as they appear and mark them done when Docker confirms they are pulled.
# Считаем слои по мере их появления и отмечаем завершёнными когда Docker подтверждает скачивание.
if "Pulling fs layer" in line or "Waiting" in line:
p.log(s)
if "Pulling fs layer" in s or "Waiting" in s:
layers_total += 1
elif "Pull complete" in line or "Already exists" in line:
elif "Pull complete" in s or "Already exists" in s:
layers_done += 1
if on_progress and layers_total > 0:
on_progress(5 + layers_done / layers_total * 90)
proc.wait()
if proc.returncode in (-9, -15):
if layers_total > 0:
p.set(lo + layers_done / layers_total * (hi - lo), f"{name}: слои")
rc = process.stream(["docker", "pull", tag], on_line=on_line)
if rc in (-9, -15):
return False
if proc.returncode == 0:
write(f"[green][ok][/green] {name}")
if on_progress:
on_progress(100)
if rc == 0:
p.raw(f"[green][/green] {name}")
return True
write(f"[red]Pull failed:[/red] {tag}")
p.raw(f"[red]Скачивание не удалось:[/red] {tag}")
return False
# The actual work - either build or pull all images depending on what the user chose.
# Each image gets its own slice of the progress bar so the overall bar advances smoothly.
# Основная работа - собираем или скачиваем все образы в зависимости от выбора пользователя.
# Каждый образ получает свой кусок прогресс-бара, чтобы общий бар двигался равномерно.
def _task_execute(screen: LogScreen, cfg: _Config) -> None:
"""Worker function that runs inside LogScreen. Builds or pulls all images in the chain
defined by the user's choices and updates the progress bar after each image.
Рабочая функция, выполняемая внутри LogScreen. Собирает или скачивает все образы из цепочки
согласно выбору пользователя и обновляет прогресс-бар после каждого образа.
def _execute(cfg: _Config) -> None:
"""Build or pull all images in the chain selected by the user's choices.
Собирает или скачивает все образы из цепочки, выбранной пользователем.
"""
try:
chain = _WEBOTS_CHAIN if cfg.variant == "webots" else _CONTROLLER_CHAIN
n = len(chain)
chain = _WEBOTS_CHAIN if cfg.variant == "webots" else _CONTROLLER_CHAIN
n = len(chain)
ok = True
fail_msg = ""
if cfg.source == "build":
screen.write(
f"[bold]Building {n} image(s) — "
f"ROS {cfg.ros_version}{cfg.build_type}[/bold]\n"
)
if cfg.source == "build":
title = f"Сборка {n} образ(ов) — ROS {cfg.ros_version}{cfg.build_type}"
with StepProgress(title) as p:
for i, name in enumerate(chain):
if screen.is_stopped():
return
lo = i / n * 100
hi = (i + 1) / n * 100
screen.set_progress(lo, f"Image {i + 1}/{n}: building {name}...")
lo, hi = i / n * 100, (i + 1) / n * 100
p.set(lo, f"Образ {i + 1}/{n}: {name}")
tag = f"{cfg.image_prefix}:{name}-{cfg.ros_version}"
dockerfile = _DOCKER_DIR / cfg.ros_version / name / "Dockerfile"
if not dockerfile.exists():
screen.write(f"[red]Dockerfile not found:[/red] {dockerfile}")
if not screen.is_stopped():
screen.finish(False)
return
ok, fail_msg = False, f"Dockerfile не найден: {dockerfile}"
break
ctx = _PROJECT_DIR if name in _NEEDS_PROJECT_CTX else dockerfile.parent
parent_name = _IMAGE_PARENT.get(name)
parent_tag = (
f"{cfg.image_prefix}:{parent_name}-{cfg.ros_version}"
if parent_name else None
)
ok = _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,
register_proc=screen.set_proc,
)
if screen.is_stopped():
return
if not ok:
screen.finish(False)
return
screen.set_progress(hi)
if not screen.is_stopped():
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]."
)
else:
short = "webots" if cfg.variant == "webots" else "iiwa"
suffix = "-dev" if cfg.build_type == "dev" else ""
full_ref = f"{cfg.hub_repo}:{short}-{cfg.ros_version}{suffix}"
screen.write(
f"[bold]Pulling from {cfg.hub_repo}"
f"ROS {cfg.ros_version}{cfg.build_type}[/bold]\n"
)
screen.set_progress(0, f"Pulling {full_ref}...")
ok = _pull_image(
short, full_ref, screen.write,
on_progress=lambda p: screen.set_progress(p, f"Pulling {full_ref}..."),
register_proc=screen.set_proc,
)
if screen.is_stopped():
return
if not ok:
screen.finish(False)
return
screen.set_progress(100, "Pull complete")
screen.write(f"\n[green]Done.[/green] Image ready: [bold]{full_ref}[/bold].")
if not screen.is_stopped():
screen.finish(True)
except Exception as exc:
if not screen.is_stopped():
screen.write(f"\n[red]Error:[/red] {exc}")
screen.finish(False)
parent_tag = (f"{cfg.image_prefix}:{parent_name}-{cfg.ros_version}"
if parent_name else None)
if not _build_image(name, tag, dockerfile, ctx, p, lo, hi,
parent_tag, cfg.build_type):
ok, fail_msg = False, f"Сборка образа {name} не удалась"
break
if ok:
p.set(100, "Готово")
done(ok, f"Образы готовы: {cfg.image_prefix}:<name>-{cfg.ros_version}"
if ok else fail_msg)
else:
short = "webots" if cfg.variant == "webots" else "iiwa"
suffix = "-dev" if cfg.build_type == "dev" else ""
full_ref = f"{cfg.hub_repo}:{short}-{cfg.ros_version}{suffix}"
with StepProgress(f"Скачивание из {cfg.hub_repo} — ROS {cfg.ros_version}") as p:
if _pull_image(short, full_ref, p, 0, 100):
p.set(100, "Готово")
else:
ok, fail_msg = False, f"Скачивание {full_ref} не удалось"
done(ok, f"Образ готов: {full_ref}" if ok else fail_msg)
# Scan the docker/ directory for subdirectories named after ROS versions (e.g. jazzy).
# If nothing is found we fall back to "jazzy" so the wizard still works.
# Сканируем директорию docker/ на наличие поддиректорий с именами версий ROS (например jazzy).
# Если ничего не найдено, используем "jazzy" по умолчанию, чтобы мастер всё равно работал.
def _discover_versions() -> List[str]:
"""Return a sorted list of ROS versions found in docker/. Jazzy is placed first.
Falls back to ["jazzy"] if the directory does not exist or is empty.
Возвращает отсортированный список версий ROS найденных в docker/. Jazzy идёт первым.
Возвращает ["jazzy"] если директория не существует или пуста.
"""Return ROS versions found in docker/ (jazzy first). Falls back to ["jazzy"].
Возвращает версии ROS из docker/ (jazzy первым). По умолчанию ["jazzy"].
"""
if not _DOCKER_DIR.exists():
return ["jazzy"]
dirs = sorted(d.name for d in _DOCKER_DIR.iterdir() if d.is_dir())
# Put jazzy first so it is the pre-selected default in the wizard.
# Ставим jazzy первым, чтобы он был предвыбранным по умолчанию в мастере.
if "jazzy" in dirs:
dirs = ["jazzy"] + [d for d in dirs if d != "jazzy"]
return dirs or ["jazzy"]
# Multi-step wizard that collects all build options before starting the actual image build.
# Многошаговый мастер, который собирает все параметры сборки перед запуском фактической сборки образа.
class _Wizard(App[None]):
"""Five-step wizard: ROS version -> source (pull/build) -> variant -> build type -> repo/prefix.
Collects all options, then hands off to LogScreen which runs _task_execute.
Пятишаговый мастер: версия ROS -> источник (pull/build) -> вариант -> тип сборки -> репо/префикс.
Собирает все параметры, затем передаёт управление LogScreen который запускает _task_execute.
"""
CSS = SCREEN_CSS
def __init__(self, versions: List[str], default_version: str = "jazzy"):
super().__init__()
self.versions = versions
self.default_version = default_version
self._state: dict = {}
def on_mount(self) -> None:
self._ask_version()
def _ask_version(self) -> None:
self.push_screen(
PickScreen("Step 1 of 5", "Select ROS version:", self.versions, self.default_version),
self._got_version,
)
def _got_version(self, v: Optional[str]) -> None:
if v is None:
self.exit()
return
self._state["ros_version"] = v
self.push_screen(
PickScreen(
"Step 2 of 5", "Source:",
["Pull from Docker Hub", "Build locally"],
"Pull from Docker Hub",
),
self._got_source,
)
def _got_source(self, v: Optional[str]) -> None:
if v is None:
self.exit()
return
self._state["source"] = "build" if v == "Build locally" else "pull"
self.push_screen(
PickScreen(
"Step 3 of 5",
"What to install:",
[
"Controller only — ros-core, ros-base, ros-iiwa7",
"Controller with Webots — ros-core, ros-base, ros-iiwa7-webots",
],
"Controller only — ros-core, ros-base, ros-iiwa7",
),
self._got_variant,
)
def _got_variant(self, v: Optional[str]) -> None:
if v is None:
self.exit()
return
self._state["variant"] = "webots" if v.startswith("Controller with Webots") else "controller"
self.push_screen(
PickScreen("Step 4 of 5", "Build type:", ["release", "dev"], "release"),
self._got_build_type,
)
def _got_build_type(self, v: Optional[str]) -> None:
if v is None:
self.exit()
return
self._state["build_type"] = v or "release"
# Pull needs a Hub repo name, build needs a local image prefix.
# Для pull нужно имя репозитория на Hub, для build - локальный префикс образов.
if self._state["source"] == "pull":
self.push_screen(
InputScreen("Step 5 of 5", "Docker Hub repository:", _DEFAULT_HUB_REPO),
self._got_hub_repo,
)
else:
self.push_screen(
InputScreen("Step 5 of 5", "Image prefix:", _DEFAULT_PREFIX),
self._got_prefix,
)
def _got_hub_repo(self, v: Optional[str]) -> None:
if v is None:
self.exit()
return
self._state["hub_repo"] = v
self._finish()
def _got_prefix(self, v: Optional[str]) -> None:
if v is None:
self.exit()
return
self._state["image_prefix"] = v
self._finish()
def _finish(self) -> None:
# Assemble the config and hand it off to the log screen that does the actual work.
# Собираем конфиг и передаём его экрану лога, который выполняет фактическую работу.
s = self._state
cfg = _Config(
ros_version=s["ros_version"],
variant=s["variant"],
source=s["source"],
build_type=s["build_type"],
image_prefix=s.get("image_prefix", _DEFAULT_PREFIX),
hub_repo=s.get("hub_repo", _DEFAULT_HUB_REPO),
)
title = (
f"Building Docker images — ROS {cfg.ros_version}"
if cfg.source == "build"
else f"Pulling Docker image — ROS {cfg.ros_version}"
)
self.push_screen(
LogScreen(title, lambda screen: _task_execute(screen, cfg), show_progress=True),
lambda _: self.exit(),
)
def register(subparsers: argparse._SubParsersAction) -> None:
p = subparsers.add_parser("docker-setup", help="Build or pull Docker images for KUKA iiwa7")
p.set_defaults(func=run)
@@ -403,10 +177,52 @@ def register(subparsers: argparse._SubParsersAction) -> None:
def run(args: argparse.Namespace) -> None:
if not shutil.which("docker"):
from rich.console import Console
Console().print("[red]Error:[/red] Docker is not installed or not on PATH.")
ui.error("Docker не установлен или отсутствует в PATH.")
sys.exit(1)
ui.header("Настройка Docker", "сборка или скачивание образов KUKA iiwa7")
versions = _discover_versions()
default = "jazzy" if "jazzy" in versions else versions[0]
_Wizard(versions=versions, default_version=default).run()
ros_version = ui.select("Версия ROS:", versions, default)
if ros_version is None:
return
src = ui.select("Источник:", ["Скачать с Docker Hub", "Собрать локально"],
"Скачать с Docker Hub")
if src is None:
return
source = "build" if src == "Собрать локально" else "pull"
variant_v = ui.select(
"Что установить:",
["Только контроллер — ros-core, ros-base, ros-iiwa7",
"Контроллер с Webots — ros-core, ros-base, ros-iiwa7-webots"],
"Только контроллер — ros-core, ros-base, ros-iiwa7",
)
if variant_v is None:
return
variant = "webots" if variant_v.startswith("Контроллер с Webots") else "controller"
build_type = ui.select("Тип сборки:", ["release", "dev"], "release")
if build_type is None:
return
image_prefix, hub_repo = _DEFAULT_PREFIX, _DEFAULT_HUB_REPO
if source == "pull":
v = ui.text("Репозиторий Docker Hub:", _DEFAULT_HUB_REPO)
if v is None:
return
hub_repo = v
else:
v = ui.text("Префикс образов:", _DEFAULT_PREFIX)
if v is None:
return
image_prefix = v
cfg = _Config(
ros_version=ros_version, variant=variant, source=source,
build_type=build_type, image_prefix=image_prefix, hub_repo=hub_repo,
)
_execute(cfg)
+240 -389
View File
@@ -5,11 +5,10 @@ import os
import shutil
import subprocess
from pathlib import Path
from typing import Callable, List, Optional
from textual.app import App
from cobot.tui import SCREEN_CSS, LogScreen, PickScreen
from cobot import process, ui
from cobot import privilege
from cobot.ui import done, header
from cobot.commands.docker_setup import run as _docker_setup
# Root directory of the project, used as the working directory for colcon builds.
@@ -28,9 +27,12 @@ _WEBOTS_VERSION = "2025a"
# Директория с shell-скриптами, используемыми этой командой.
_SCRIPTS_DIR = _PROJECT_DIR / "scripts"
# Type alias for the callable used to write a line to the TUI log screen.
# Псевдоним типа для функции записи строки в лог TUI.
Write = Callable[[str], None]
# apt packages that must exist before rosdep can install the pip-based keys
# (python3-pip / dev / venv). Their absence is what produced the "pip is not
# installed" failure in the screenshots.
# apt-пакеты, необходимые до того как rosdep сможет установить pip-зависимости.
# Именно их отсутствие давало ошибку "pip is not installed" на скриншотах.
_APT_PREREQS = ["python3-pip", "python3-dev", "python3-venv"]
# OS and tool detection helpers
@@ -38,9 +40,7 @@ Write = Callable[[str], None]
def _detect_ubuntu_2404() -> bool:
"""Return True if the current OS is Ubuntu 24.04 (Noble).
Reads /etc/os-release and checks the ID and VERSION_ID fields.
Возвращает True, если текущая ОС - Ubuntu 24.04 (Noble).
Читает /etc/os-release и проверяет поля ID и VERSION_ID.
"""
path = Path("/etc/os-release")
if not path.exists():
@@ -55,7 +55,6 @@ def _detect_ubuntu_2404() -> bool:
def _detect_ros2() -> bool:
"""Return True if ROS2 Jazzy is already installed under /opt/ros/jazzy.
Возвращает True, если ROS2 Jazzy уже установлен в /opt/ros/jazzy.
"""
return Path(f"/opt/ros/{_DISTRO}").is_dir()
@@ -63,7 +62,6 @@ def _detect_ros2() -> bool:
def webots_installed() -> bool:
"""Return True if the Webots binary is available on PATH.
Возвращает True, если бинарный файл Webots доступен в PATH.
"""
return shutil.which("webots") is not None
@@ -77,8 +75,6 @@ def _ros2_env() -> dict:
os.environ if the setup file does not exist yet.
Формирует словарь окружения с переменными ROS2, полученными из setup.bash.
Запускает /opt/ros/jazzy/setup.bash в подпроцессе, перехватывает все
экспортированные переменные и объединяет их с копией os.environ.
Возвращает чистый os.environ если файл setup.bash ещё не существует.
"""
setup = Path(f"/opt/ros/{_DISTRO}/setup.bash")
@@ -96,15 +92,13 @@ def _ros2_env() -> dict:
# CMake's find_package(Python3) ignores PATH and uses its own search logic,
# so we must pin it explicitly to the system Python where catkin_pkg is installed.
# PATH reordering alone is not enough.
# CMake игнорирует PATH при поиске Python через find_package(Python3),
# поэтому явно указываем системный Python, где установлен catkin_pkg.
# Одного изменения PATH недостаточно.
env["Python3_EXECUTABLE"] = "/usr/bin/python3"
env["PYTHON_EXECUTABLE"] = "/usr/bin/python3"
# Also keep PATH clean so other tools (rosdep, colcon itself) use system Python.
# Заодно чистим PATH чтобы другие инструменты тоже использовали системный Python.
# Keep PATH clean so other tools (rosdep, colcon itself) use system Python.
# Чистим PATH чтобы другие инструменты тоже использовали системный Python.
_SYSTEM_PATHS = ["/usr/bin", "/usr/local/bin"]
existing = env.get("PATH", "").split(":")
env["PATH"] = ":".join(
@@ -113,393 +107,268 @@ def _ros2_env() -> dict:
return env
# Subprocess runner helpers
# Вспомогательные функции для запуска подпроцессов
def _run_logged(
cmd: List[str],
write: Write,
env: dict | None = None,
cwd=None,
register_proc: Callable | None = None,
) -> None:
"""Run a command and stream every non-empty output line to the TUI log.
Raises RuntimeError if the process exits with a non-zero code (SIGKILL is
treated as a normal cancellation and does not raise).
Запускает команду и передаёт каждую непустую строку вывода в лог TUI.
Выбрасывает RuntimeError если процесс завершился с ненулевым кодом
(SIGKILL считается нормальной отменой и не вызывает исключение).
# Bash-script runner that understands PROGRESS:<pct>:<label> markers
# Запуск bash-скриптов с поддержкой маркеров PROGRESS:<pct>:<метка>
def _run_script(script: Path, title: str) -> int:
"""Run a shell script, streaming its output to a live log and advancing the
progress bar from PROGRESS:<pct>:<label> markers (which are not echoed raw).
Returns the script exit code.
Запускает shell-скрипт, транслируя вывод в живой лог и продвигая прогресс-бар по
маркерам PROGRESS:<pct>:<метка> (сами маркеры не печатаются). Возвращает код возврата.
"""
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
env=env or os.environ,
cwd=cwd,
)
if register_proc:
register_proc(proc)
for line in proc.stdout:
s = line.rstrip()
if s:
write(s)
proc.wait()
if proc.returncode not in (0, -9):
raise RuntimeError(f"Command failed: {cmd[0]}")
if not script.exists():
header(title)
ui.error(f"Скрипт не найден: {script}")
done(False, "Скрипт отсутствует")
return 1
with process.StepProgress(title) as p:
def on_line(s: str) -> None:
if s.startswith("PROGRESS:"):
parts = s.split(":", 2)
try:
p.set(float(parts[1]), parts[2] if len(parts) > 2 else "")
except (ValueError, IndexError):
pass
return
if s:
p.log(s)
rc = process.stream(["bash", str(script)], cwd=str(_PROJECT_DIR), on_line=on_line)
ok = rc in (0, -9, -15)
done(ok, "Готово" if ok else f"Скрипт завершился с кодом {rc}")
return rc
# Tasks - long-running functions executed inside a LogScreen background thread
# Задачи - долгие функции, выполняемые в фоновом потоке внутри LogScreen
def _run_script(script: Path, screen: LogScreen) -> None:
"""Run a shell script, stream its output to the TUI log, and parse
PROGRESS:<pct>:<label> markers to update the progress bar.
Raises RuntimeError if the script exits with a non-zero code.
Запускает shell-скрипт, транслирует вывод в лог TUI и разбирает маркеры
PROGRESS:<pct>:<метка> для обновления прогресс-бара.
Выбрасывает RuntimeError если скрипт завершился с ненулевым кодом.
"""
proc = subprocess.Popen(
["bash", str(script)],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, cwd=_PROJECT_DIR,
)
screen.set_proc(proc)
for line in proc.stdout:
s = line.rstrip()
if s.startswith("PROGRESS:"):
# Format emitted by scripts: PROGRESS:<pct>:<label>
# Формат, выводимый скриптами: PROGRESS:<pct>:<метка>
parts = s.split(":", 2)
try:
screen.set_progress(float(parts[1]), parts[2] if len(parts) > 2 else "")
except (ValueError, IndexError):
pass
elif s:
screen.write(s)
proc.wait()
if proc.returncode not in (0, -9):
raise RuntimeError(f"Script failed (exit {proc.returncode}): {script.name}")
def _task_install(screen: LogScreen, pkg: str) -> None:
"""Run the ROS2 Jazzy installation shell script for the chosen variant (desktop / ros-base).
The script emits PROGRESS: markers so the bar advances during installation.
def install_ros2(pkg: str) -> bool:
"""Run the ROS2 Jazzy install shell script for the chosen variant (desktop / ros-base).
Запускает shell-скрипт установки ROS2 Jazzy для выбранного варианта (desktop / ros-base).
Скрипт выводит маркеры PROGRESS:, чтобы прогресс-бар обновлялся во время установки.
"""
script = _SCRIPTS_DIR / f"setup_ros2_{pkg.replace('-', '_')}.sh"
rc = _run_script(script, f"Установка ROS2 {_DISTRO} ({pkg})")
return rc in (0, -9, -15)
def install_webots() -> bool:
"""Run the Webots installation shell script.
Запускает shell-скрипт установки Webots.
"""
script = _SCRIPTS_DIR / "install_webots.sh"
rc = _run_script(script, f"Установка Webots {_WEBOTS_VERSION}")
return rc in (0, -9, -15)
# Build prerequisites
# Предусловия сборки
def _missing_apt_prereqs() -> list[str]:
"""Return the subset of _APT_PREREQS that is not currently installed via dpkg.
Возвращает подмножество _APT_PREREQS, которое сейчас не установлено через dpkg.
"""
missing = []
for pkg in _APT_PREREQS:
r = subprocess.run(
["dpkg-query", "-W", "-f=${Status}", pkg],
capture_output=True, text=True,
)
if "install ok installed" not in r.stdout:
missing.append(pkg)
return missing
def _ensure_root_pip_break(p: process.StepProgress) -> None:
"""Let root's pip override PEP 668, scoped to /root/.config/pip/pip.conf.
rosdep installs the pip-based rosdep keys (fastapi, uvicorn, multipart, fastmcp)
as root via sudo; on Ubuntu 24.04 that is blocked by PEP 668 unless break-system-
packages is allowed. Writing root's pip config is idempotent, reversible (just
delete the file), and does not touch the user's own pip configuration.
Разрешает pip от root обходить PEP 668, ограничиваясь /root/.config/pip/pip.conf.
rosdep ставит pip-зависимости от root через sudo; на Ubuntu 24.04 это блокируется
PEP 668, пока не разрешён break-system-packages. Запись конфига pip от root
идемпотентна, обратима (удалить файл) и не трогает пользовательский pip.
"""
snippet = (
"mkdir -p /root/.config/pip && "
"( grep -qs 'break-system-packages' /root/.config/pip/pip.conf || "
"printf '[global]\\nbreak-system-packages = true\\n' "
">> /root/.config/pip/pip.conf )"
)
process.stream(privilege.sudo(["bash", "-c", snippet]), on_line=p.log)
def _register_rosdep_source(p: process.StepProgress, env: dict) -> None:
"""Register the project's local rosdep.yaml as a rosdep source and run rosdep update.
Only re-writes / updates when the source file is missing or out of date.
Регистрирует локальный rosdep.yaml проекта как источник rosdep и запускает rosdep update.
Перезаписывает/обновляет только если файл-источник отсутствует или устарел.
"""
rosdep_yaml = _PROJECT_DIR / "rosdep.yaml"
if not rosdep_yaml.exists():
return
sources_list = Path("/etc/ros/rosdep/sources.list.d/50-kuka-local.list")
entry = f"yaml file://{rosdep_yaml}\n"
try:
# "desktop" -> setup_ros2_desktop.sh, "ros-base" -> setup_ros2_ros_base.sh
script = _SCRIPTS_DIR / f"setup_ros2_{pkg.replace('-', '_')}.sh"
if not script.exists():
screen.write(f"[red]Script not found:[/red] {script}")
screen.finish(False)
return
screen.set_progress(0, "Starting installation...")
_run_script(script, screen)
if not screen.is_stopped():
screen.set_progress(100, "Done")
screen.write(f"\n[green]ROS2 {_DISTRO} ({pkg}) installed successfully.[/green]")
screen.finish(True)
except Exception as exc:
if not screen.is_stopped():
screen.write(f"\n[red]Error:[/red] {exc}")
screen.finish(False)
current = sources_list.read_text() if sources_list.exists() else ""
except Exception:
current = ""
if current == entry:
return
snippet = (
"mkdir -p /etc/ros/rosdep/sources.list.d && "
f"printf '%s\\n' 'yaml file://{rosdep_yaml}' > {sources_list}"
)
process.stream(privilege.sudo(["bash", "-c", snippet]), on_line=p.log)
p.log(f"Зарегистрирован локальный источник rosdep: {rosdep_yaml}")
process.stream(["rosdep", "update"], env=env, cwd=str(_PROJECT_DIR), on_line=p.log)
def _task_build(screen: LogScreen) -> None:
"""Build the project workspace using rosdep and colcon.
Step 1 - runs "rosdep install --from-paths src" to pull in all package
dependencies declared in the src/ directory.
Step 2 - runs "colcon build --symlink-install" to compile every package.
Both commands receive a copy of os.environ extended with the sourced ROS2
setup so that ament CMake macros and ROS2 packages are visible even if the
user has not yet sourced setup.bash in this terminal session.
Собирает рабочее пространство проекта с помощью rosdep и colcon.
Шаг 1 - запускает "rosdep install --from-paths src" для установки всех
зависимостей пакетов, объявленных в директории src/.
Шаг 2 - запускает "colcon build --symlink-install" для компиляции каждого пакета.
Обе команды получают копию os.environ с подключённым окружением ROS2, так что
макросы ament CMake и пакеты ROS2 видны даже если пользователь ещё не выполнил
source setup.bash в этой сессии терминала.
def _count_colcon_packages(env: dict) -> int:
"""Count colcon packages under src/ so the build bar can show X / total.
Считает пакеты colcon в src/, чтобы бар сборки показывал X / всего.
"""
try:
env = _ros2_env()
if not shutil.which("colcon") and not Path(f"/opt/ros/{_DISTRO}/bin/colcon").exists():
screen.write("[red]colcon not found.[/red]")
screen.write(f"Source ROS2 first: [bold]source /opt/ros/{_DISTRO}/setup.bash[/bold]")
screen.finish(False)
return
screen.set_progress(0, "Installing dependencies...")
screen.write("[bold]Step 1 / 2 - rosdep install[/bold]\n")
rosdep_yaml = _PROJECT_DIR / "rosdep.yaml"
sources_list = Path("/etc/ros/rosdep/sources.list.d/50-kuka-local.list")
rosdep_entry = f"yaml file://{rosdep_yaml}\n"
if rosdep_yaml.exists() and (
not sources_list.exists() or sources_list.read_text() != rosdep_entry
):
try:
sources_list.write_text(rosdep_entry)
screen.write(f"Registered local rosdep source: {rosdep_yaml}")
_run_logged(["rosdep", "update"], screen.write, env=env, cwd=_PROJECT_DIR)
except PermissionError:
_run_logged(
["sudo", "bash", "-c",
f"echo '{rosdep_entry.strip()}' > {sources_list}"],
screen.write, env=env,
)
_run_logged(["rosdep", "update"], screen.write, env=env, cwd=_PROJECT_DIR)
_run_logged(
["rosdep", "install", "--from-paths", "src", "-i", "-r", "-y"],
screen.write,
env=env,
cwd=_PROJECT_DIR,
register_proc=screen.set_proc,
)
if screen.is_stopped():
return
screen.set_progress(30, "Building...")
list_result = subprocess.run(
["colcon", "list", "--base-paths", "src"], capture_output=True, text=True,
cwd=_PROJECT_DIR, env=env,
)
total = max(len([l for l in list_result.stdout.splitlines() if l.strip()]), 1)
screen.write(f"\n[bold]Step 2 / 2 - colcon build ({total} packages)[/bold]\n")
built = 0
def _track(line: str) -> None:
"""Update the progress bar each time colcon finishes a package.
Обновляет прогресс-бар каждый раз, когда colcon завершает пакет.
"""
nonlocal built
screen.write(line)
if "Finished <<<" in line or "Failed <<<" in line:
built += 1
screen.set_progress(
30 + built / total * 70,
f"{built} / {total} packages done",
)
_run_logged(
["colcon", "build", "--base-paths", "src"],
_track,
env=env,
cwd=_PROJECT_DIR,
register_proc=screen.set_proc,
)
if not screen.is_stopped():
screen.set_progress(100, "Build complete")
screen.write("\nActivate workspace: [bold]source install/setup.bash[/bold]")
screen.finish(True)
except Exception as exc:
if not screen.is_stopped():
screen.write(f"\n[red]Error:[/red] {exc}")
screen.finish(False)
r = subprocess.run(
["colcon", "list", "--base-paths", "src"],
capture_output=True, text=True, cwd=str(_PROJECT_DIR), env=env,
)
return max(len([l for l in r.stdout.splitlines() if l.strip()]), 1)
def _task_install_webots(screen: LogScreen) -> None:
"""Run the Webots installation shell script, streaming output and progress to the TUI.
def build_workspace() -> bool:
"""Build the workspace: apt prerequisites -> rosdep install -> colcon build.
Запускает shell-скрипт установки Webots, транслируя вывод и прогресс в TUI.
Step 1 guarantees python3-pip/dev/venv and allows root pip under PEP 668 so the
pip-based rosdep keys install cleanly. Step 2 runs rosdep install with
PIP_BREAK_SYSTEM_PACKAGES=1. Step 3 compiles every package with live progress.
Собирает workspace: apt-предусловия -> rosdep install -> colcon build.
Шаг 1 гарантирует python3-pip/dev/venv и разрешает pip от root под PEP 668. Шаг 2
запускает rosdep install с PIP_BREAK_SYSTEM_PACKAGES=1. Шаг 3 компилирует все пакеты.
"""
try:
script = _SCRIPTS_DIR / "install_webots.sh"
if not script.exists():
screen.write(f"[red]Script not found:[/red] {script}")
screen.finish(False)
return
screen.set_progress(0, "Starting Webots installation...")
_run_script(script, screen)
if not screen.is_stopped():
screen.set_progress(100, "Done")
screen.write(f"\n[green]Webots {_WEBOTS_VERSION} installed successfully.[/green]")
screen.finish(True)
except Exception as exc:
if not screen.is_stopped():
screen.write(f"\n[red]Error:[/red] {exc}")
screen.finish(False)
env = _ros2_env()
env["PIP_BREAK_SYSTEM_PACKAGES"] = "1"
if not shutil.which("colcon") and not Path(f"/opt/ros/{_DISTRO}/bin/colcon").exists():
header("Сборка проекта")
ui.error("colcon не найден.")
ui.note(f"Сначала выполните: source /opt/ros/{_DISTRO}/setup.bash")
done(False, "colcon недоступен")
return False
# TUI application - orchestrates screens and user choices
# TUI приложение - управляет экранами и выборами пользователя
class _LocalSetupApp(App[Optional[str]]):
"""Main TUI application for the local-setup command.
ok = True
fail_msg = ""
Guides the user through: install ROS2 choice, OS check, version choice,
installation log, build log, and optional Webots installation.
Returns "docker" if the user opts for Docker setup, None otherwise.
Главное TUI приложение для команды local-setup.
Проводит пользователя через: выбор установки ROS2, проверку ОС, выбор версии,
лог установки, лог сборки и опциональную установку Webots.
Возвращает "docker" если пользователь выбирает Docker, иначе None.
"""
CSS = SCREEN_CSS
def on_mount(self) -> None:
self.push_screen(
PickScreen(
"local-setup",
"Install ROS2 Jazzy?",
["Yes, install", "No, exit"],
"Yes, install",
),
self._on_install_choice,
)
def _on_install_choice(self, choice: Optional[str]) -> None:
"""Handle the initial yes/no choice to install ROS2.
Обрабатывает начальный выбор да/нет для установки ROS2.
"""
if not choice or choice.startswith("No"):
self.exit(None)
return
if not _detect_ubuntu_2404():
self.push_screen(
PickScreen(
"Unsupported OS",
"Ubuntu 24.04 not detected. Set up the environment via Docker instead?",
["Yes, run docker-setup", "No, exit"],
"Yes, run docker-setup",
),
self._on_docker_choice,
with process.StepProgress("Сборка проекта") as p:
# --- Шаг 1/3: системные зависимости pip (apt) ---
p.raw("[bold]Шаг 1/3 — системные зависимости pip (apt)[/bold]")
p.set(0, "Проверка python3-pip / dev / venv...")
missing = _missing_apt_prereqs()
if missing:
p.log(f"Установка: {', '.join(missing)}")
process.stream(privilege.sudo(["apt-get", "update", "-q"]), env=env, on_line=p.log)
rc = process.stream(
privilege.sudo(["apt-get", "install", "-y", *missing]),
env=env, on_line=p.log,
)
if rc not in (0, -9, -15):
ok, fail_msg = False, "Не удалось установить apt-зависимости"
else:
self.push_screen(
PickScreen(
"ROS2 version",
"Which ROS2 Jazzy variant do you want to install?",
["Desktop (full install, includes GUI tools)", "Base (minimal, no GUI)"],
"Desktop (full install, includes GUI tools)",
),
self._on_version_choice,
p.log("python3-pip / dev / venv уже установлены")
if ok:
_ensure_root_pip_break(p)
# --- Шаг 2/3: rosdep install ---
if ok:
p.set(10, "rosdep install...")
p.raw("\n[bold]Шаг 2/3 — rosdep install[/bold]")
_register_rosdep_source(p, env)
rc = process.stream(
["rosdep", "install", "--from-paths", "src", "-i", "-r", "-y"],
env=env, cwd=str(_PROJECT_DIR), on_line=p.log,
)
if rc not in (0, -9, -15):
ok, fail_msg = False, "rosdep install завершился с ошибкой"
def _on_docker_choice(self, choice: Optional[str]) -> None:
"""Exit the app signalling whether docker-setup should be launched.
Завершает приложение, сигнализируя нужно ли запустить docker-setup.
"""
self.exit("docker" if choice and choice.startswith("Yes") else None)
# --- Шаг 3/3: colcon build ---
if ok:
total = _count_colcon_packages(env)
p.set(30, f"0 / {total} пакетов")
p.raw(f"\n[bold]Шаг 3/3 — colcon build ({total} пакетов)[/bold]")
built = 0
def _on_version_choice(self, choice: Optional[str]) -> None:
"""Start the installation log screen for the chosen ROS2 variant.
Запускает экран лога установки для выбранного варианта ROS2.
"""
if not choice:
self.exit(None)
return
pkg = "desktop" if choice.startswith("Desktop") else "ros-base"
self.push_screen(
LogScreen(
f"Installing ROS2 Jazzy ({pkg})",
lambda s: _task_install(s, pkg),
show_progress=True,
),
lambda _: self._after_install(),
)
def _on_build(s: str) -> None:
nonlocal built
if s:
p.log(s)
if "Finished <<<" in s or "Failed <<<" in s:
built += 1
p.set(30 + built / total * 70, f"{built} / {total} пакетов")
def _after_install(self) -> None:
"""After installation, ask whether to build the project workspace now.
После установки спрашивает, нужно ли собрать рабочее пространство прямо сейчас.
"""
self.push_screen(
PickScreen(
"Build",
"Build the project workspace now?\n(runs rosdep install + colcon build)",
["Yes, build now", "No, skip"],
"Yes, build now",
),
self._on_build_choice,
)
def _on_build_choice(self, choice: Optional[str]) -> None:
"""Start the build log screen or skip directly to the Webots prompt.
Запускает экран сборки или пропускает к вопросу про Webots.
"""
if not choice or choice.startswith("No"):
self._after_build()
return
self.push_screen(
LogScreen("Building project", _task_build, show_progress=True),
lambda _: self._after_build(),
)
def _after_build(self) -> None:
"""After the build, offer to install Webots if it is not already present.
После сборки предлагает установить Webots если он ещё не установлен.
"""
if webots_installed():
self.exit(None)
return
self.push_screen(
PickScreen(
"Webots",
f"Install Webots {_WEBOTS_VERSION} simulator?",
[f"Yes, install Webots {_WEBOTS_VERSION}", "No, skip"],
"No, skip",
),
self._on_webots_choice,
)
def _on_webots_choice(self, choice: Optional[str]) -> None:
"""Start the Webots installer or exit depending on the user choice.
Запускает установщик Webots или завершает работу в зависимости от выбора.
"""
if choice and choice.startswith("Yes"):
subprocess.run(["sudo", "-v"], check=False)
self.push_screen(
LogScreen(
f"Installing Webots {_WEBOTS_VERSION}",
_task_install_webots,
show_progress=True,
),
lambda _: self.exit(None),
rc = process.stream(
["colcon", "build", "--base-paths", "src"],
env=env, cwd=str(_PROJECT_DIR), on_line=_on_build,
)
else:
self.exit(None)
if rc not in (0, -9, -15):
ok, fail_msg = False, "colcon build завершился с ошибкой"
else:
p.set(100, "Готово")
done(ok, "Сборка завершена" if ok else fail_msg)
if ok:
ui.note("Активируйте окружение: source install/setup.bash")
return ok
class WebotsInstallApp(App[bool]):
"""Standalone TUI app for installing Webots, used by the run command.
Launched by run.py when the user starts a local simulation but Webots
is not installed yet.
Отдельное TUI приложение для установки Webots, используемое командой run.
Запускается из run.py когда пользователь запускает локальную симуляцию,
но Webots ещё не установлен.
# Interactive flow
# Интерактивный сценарий
def run(args: argparse.Namespace) -> None:
"""Guide the user through installing ROS2 Jazzy and building the workspace.
Проводит пользователя через установку ROS2 Jazzy и сборку workspace.
"""
header("Локальная установка", "ROS2 Jazzy + сборка проекта")
CSS = SCREEN_CSS
choice = ui.select("Установить ROS2 Jazzy?", ["Да, установить", "Нет, выход"],
"Да, установить")
if not choice or choice.startswith("Нет"):
return
def on_mount(self) -> None:
self.push_screen(
LogScreen(
f"Installing Webots {_WEBOTS_VERSION}",
_task_install_webots,
show_progress=True,
),
self.exit,
# Acquire sudo once, up front, with the masked prompt + keep-alive thread.
# Получаем sudo один раз, заранее, с маскированным вводом + keep-alive потоком.
if not privilege.ensure_sudo():
return
if not _detect_ubuntu_2404():
v = ui.select(
"Ubuntu 24.04 не обнаружена. Настроить окружение через Docker?",
["Да, запустить docker-setup", "Нет, выход"],
"Да, запустить docker-setup",
)
if v and v.startswith("Да"):
_docker_setup(args)
return
variant = ui.select(
"Какой вариант ROS2 Jazzy установить?",
["Desktop (полный, с GUI-инструментами)", "Base (минимальный, без GUI)"],
"Desktop (полный, с GUI-инструментами)",
)
if not variant:
return
pkg = "desktop" if variant.startswith("Desktop") else "ros-base"
if not install_ros2(pkg):
return
if ui.confirm("Собрать workspace сейчас? (rosdep install + colcon build)", default=True):
build_workspace()
if not webots_installed():
if ui.confirm(f"Установить симулятор Webots {_WEBOTS_VERSION}?", default=False):
install_webots()
# Entry point - registered as the "local-setup" subcommand
# Точка входа - зарегистрирована как подкоманда "local-setup"
def register(subparsers: argparse._SubParsersAction) -> None:
"""Register the local-setup subcommand with the CLI argument parser.
Регистрирует подкоманду local-setup в парсере аргументов командной строки.
"""
p = subparsers.add_parser(
@@ -507,21 +376,3 @@ def register(subparsers: argparse._SubParsersAction) -> None:
help="Install ROS2 Jazzy natively and build the project with colcon",
)
p.set_defaults(func=run)
def run(args: argparse.Namespace) -> None:
"""Entry point for the local-setup command.
Pre-caches the sudo token while the terminal is in normal mode so that
subsequent sudo calls inside the Textual TUI do not hang waiting for
a password prompt that the user cannot see.
Точка входа для команды local-setup.
Предварительно кеширует sudo-токен пока терминал в обычном режиме, чтобы
последующие вызовы sudo внутри Textual TUI не зависали ожидая запрос пароля,
который пользователь не может увидеть.
"""
subprocess.run(["sudo", "-v"], check=False)
result = _LocalSetupApp().run()
if result == "docker":
_docker_setup(args)
+63 -138
View File
@@ -5,154 +5,63 @@ import subprocess
from pathlib import Path
from typing import List, Optional
from textual.app import App
from cobot.tui import SCREEN_CSS, InputScreen, LogScreen, PickScreen
from cobot import ui, process
from cobot.commands.local_setup import _ros2_env
_PROJECT_DIR = Path(__file__).parent.parent.parent
def _task_rebuild(screen: LogScreen, packages: List[str], symlink: bool) -> None:
"""Run colcon build for the selected packages (or all if packages is empty).
Streams output to the log and tracks per-package progress.
Запускает colcon build для выбранных пакетов (или всех если packages пуст).
Транслирует вывод в лог и отслеживает прогресс по каждому пакету.
def _count_packages(packages: List[str], env: dict) -> int:
"""Count how many colcon packages will be built so we can show X / total progress.
Считает количество пакетов colcon для отображения прогресса X / всего.
"""
try:
env = _ros2_env()
# Count packages so we can show X / total progress.
# Считаем пакеты чтобы показывать X / всего в прогрессе.
list_cmd = ["colcon", "list", "--base-paths", "src"]
if packages:
list_cmd += ["--packages-select"] + packages
list_result = subprocess.run(
list_cmd, capture_output=True, text=True,
cwd=_PROJECT_DIR, env=env,
)
total = max(len([l for l in list_result.stdout.splitlines() if l.strip()]), 1)
pkg_label = " ".join(packages) if packages else "all packages"
symlink_label = " --symlink-install" if symlink else ""
screen.write(f"[bold]colcon build{symlink_label}{pkg_label}[/bold]\n")
screen.set_progress(0, f"0 / {total} packages done")
built = 0
cmd = ["colcon", "build", "--base-paths", "src"]
if symlink:
cmd.append("--symlink-install")
if packages:
cmd += ["--packages-select"] + packages
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, cwd=_PROJECT_DIR, env=env,
)
screen.set_proc(proc)
for line in proc.stdout:
s = line.rstrip()
if s:
screen.write(s)
if "Finished <<<" in line or "Failed <<<" in line:
built += 1
screen.set_progress(
built / total * 100,
f"{built} / {total} packages done",
)
proc.wait()
if screen.is_stopped():
return
if proc.returncode not in (0, -9):
screen.write("\n[red]Build failed.[/red]")
screen.finish(False)
return
screen.set_progress(100, "Done")
screen.write("\n[green]Build complete.[/green]")
screen.finish(True)
except Exception as exc:
if not screen.is_stopped():
screen.write(f"\n[red]Error:[/red] {exc}")
screen.finish(False)
list_cmd = ["colcon", "list", "--base-paths", "src"]
if packages:
list_cmd += ["--packages-select"] + packages
result = subprocess.run(list_cmd, capture_output=True, text=True,
cwd=_PROJECT_DIR, env=env)
return max(len([l for l in result.stdout.splitlines() if l.strip()]), 1)
class _RebuildApp(App[None]):
"""Rebuild wizard: optionally asks for packages and symlink flag, then runs colcon.
Мастер пересборки: опционально спрашивает пакеты и флаг symlink, затем запускает colcon.
def _rebuild(packages: List[str], symlink: bool) -> None:
"""Run colcon build for the selected packages (or all), streaming live output
with a per-package progress bar.
Запускает colcon build для выбранных пакетов (или всех), транслируя живой вывод
с прогресс-баром по пакетам.
"""
env = _ros2_env()
total = _count_packages(packages, env)
CSS = SCREEN_CSS
pkg_label = " ".join(packages) if packages else "все пакеты"
symlink_label = " --symlink-install" if symlink else ""
def __init__(self, packages: Optional[List[str]], symlink: Optional[bool]):
super().__init__()
# None means "ask the user interactively".
# None означает "спросить пользователя интерактивно".
self._packages = packages
self._symlink = symlink
cmd = ["colcon", "build", "--base-paths", "src"]
if symlink:
cmd.append("--symlink-install")
if packages:
cmd += ["--packages-select"] + packages
def on_mount(self) -> None:
if self._packages is None:
self._ask_packages()
elif self._symlink is None:
self._ask_symlink()
else:
self._start()
built = 0
def _ask_packages(self) -> None:
self.push_screen(
InputScreen(
"rebuild",
"Packages to rebuild (space-separated, leave empty for all):",
"",
note="Example: iiwa_controller iiwa_bringup",
),
self._got_packages,
)
def _parse(line: str):
nonlocal built
if "Finished <<<" in line or "Failed <<<" in line:
built += 1
return (built / total * 100, f"{built} / {total} пакетов")
return None
def _got_packages(self, value: Optional[str]) -> None:
if value is None:
self.exit()
return
self._packages = value.split() if value.strip() else []
if self._symlink is None:
self._ask_symlink()
else:
self._start()
def _ask_symlink(self) -> None:
self.push_screen(
PickScreen(
"rebuild",
"Use --symlink-install?",
["Yes", "No"],
"Yes",
),
self._got_symlink,
)
def _got_symlink(self, value: Optional[str]) -> None:
if value is None:
self.exit()
return
self._symlink = value == "Yes"
self._start()
def _start(self) -> None:
self.push_screen(
LogScreen(
"Rebuilding packages",
lambda s: _task_rebuild(s, self._packages, self._symlink),
show_progress=True,
),
lambda _: self.exit(),
)
rc = process.run_step(
f"colcon build{symlink_label}{pkg_label}",
cmd,
env=env,
cwd=str(_PROJECT_DIR),
total=100.0,
parse_progress=_parse,
success_msg="Сборка завершена",
fail_msg="Сборка завершилась с ошибкой",
)
if rc in (0, -9, -15):
ui.note("Активируйте окружение: source install/setup.bash")
def register(subparsers: argparse._SubParsersAction) -> None:
@@ -186,7 +95,23 @@ def run(args: argparse.Namespace) -> None:
"""Entry point for the rebuild command.
Точка входа для команды rebuild.
"""
# Convert empty list to None so the TUI asks interactively.
# Преобразуем пустой список в None, чтобы TUI спросил интерактивно.
packages = args.packages if args.packages else None
_RebuildApp(packages, args.symlink).run()
packages: Optional[List[str]] = args.packages if args.packages else None
if packages is None:
value = ui.text(
"Какие пакеты пересобрать? (через пробел, пусто — все)",
"",
note="Пример: iiwa_controller iiwa_bringup",
)
if value is None:
return
packages = value.split() if value.strip() else []
symlink = args.symlink
if symlink is None:
choice = ui.select("Использовать --symlink-install?", ["Да", "Нет"], "Да")
if choice is None:
return
symlink = choice == "Да"
_rebuild(packages, symlink)
+159 -242
View File
@@ -2,17 +2,13 @@ from __future__ import annotations
import argparse
import sys
from dataclasses import dataclass, field
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, List, Optional, Tuple
from typing import Any, List, Optional
from ruamel.yaml import YAML
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.screen import Screen
from textual.widgets import Footer, Static
from cobot.tui import SCREEN_CSS, InputScreen, PickScreen
from cobot import ui
_PROJECT_DIR = Path(__file__).parent.parent.parent
_CONFIG_PATH = _PROJECT_DIR / "cobot-setting.yaml"
@@ -23,34 +19,28 @@ _TOOL_ACTIVE_XACRO = (
)
_SRDF_PATH = _PROJECT_DIR / "src" / "iiwa_config" / "config" / "moveit" / "iiwa7.srdf"
# Use ruamel.yaml instead of PyYAML so comments and formatting in the config file are preserved.
# Use ruamel.yaml instead of PyYAML so comments and formatting in the config are preserved.
# Используем ruamel.yaml вместо PyYAML, чтобы комментарии и форматирование в конфиге сохранялись.
_yaml = YAML()
_yaml.preserve_quotes = True
# One question inside a configuration block.
# A field can either show a pick list (options) or a free-text input (no options).
# Один вопрос внутри блока конфигурации.
# Поле может показывать список вариантов (options) или поле для ввода текста (без options).
@dataclass
class _Field:
key: str # dot-separated path within the block, e.g. "webots.world"
question: str
default: Any
note: str = ""
options: Optional[List[str]] = None # if set - PickScreen, else - InputScreen
options: Optional[List[str]] = None # if set - select(), else - text()
def label(self) -> str:
return self.key.split(".")[-1]
# A group of related fields shown together under one "Configure X?" question.
# Группа связанных полей, показываемая вместе под одним вопросом "Настроить X?".
@dataclass
class _Block:
yaml_key: str # top-level key in cobot-setting.yaml
title: str # shown in "Configure <title>?" prompt
title: str # shown in "Настроить <title>?" prompt
fields: List[_Field]
@@ -76,15 +66,10 @@ def _build_tool_block() -> Optional[_Block]:
)
return _Block(
yaml_key="tool",
title="Tool / End-effector",
title="Инструмент / Захват",
fields=[
_Field(
"active",
"Выберите активный инструмент:",
options[0],
note=labels,
options=options,
),
_Field("active", "Выберите активный инструмент:", options[0],
note=labels, options=options),
],
)
@@ -96,76 +81,83 @@ _BLOCKS: List[_Block] = [
yaml_key="foxglove",
title="Foxglove bridge",
fields=[
_Field("enabled", "Enable Foxglove bridge?", "true",
note="Start foxglove_bridge alongside the robot node",
_Field("enabled", "Включить Foxglove bridge?", "true",
note="Запускать foxglove_bridge вместе с узлом робота",
options=["true", "false"]),
_Field("port", "WebSocket port:", "8765",
note="Port Foxglove Studio connects to (default 8765)"),
_Field("address", "Listen address:", "0.0.0.0",
note="0.0.0.0 = all interfaces, 127.0.0.1 = localhost only",
_Field("port", "Порт WebSocket:", "8765",
note="Порт, к которому подключается Foxglove Studio (по умолчанию 8765)"),
_Field("address", "Адрес прослушивания:", "0.0.0.0",
note="0.0.0.0 = все интерфейсы, 127.0.0.1 = только localhost",
options=["0.0.0.0", "127.0.0.1"]),
_Field("use_sim_time", "Use simulation time (/clock)?", "false",
note="Subscribe to /clock instead of using wall time",
_Field("use_sim_time", "Использовать симуляционное время (/clock)?", "false",
note="Подписываться на /clock вместо системного времени",
options=["false", "true"]),
_Field("debug", "Enable verbose bridge logging?", "false",
_Field("debug", "Подробное логирование bridge?", "false",
options=["false", "true"]),
_Field("num_threads", "Executor threads (0 = auto):", "0"),
_Field("num_threads", "Потоки executor (0 = авто):", "0"),
],
),
_Block(
yaml_key="web",
title="Веб-сервер (FastAPI)",
fields=[
_Field("enabled", "Включить веб-сервер?", "true",
note="Запускать FastAPI-сервер для HTTP/WebSocket-управления",
options=["true", "false"]),
_Field("host", "Адрес прослушивания:", "0.0.0.0",
note="0.0.0.0 = все интерфейсы, 127.0.0.1 = только localhost",
options=["0.0.0.0", "127.0.0.1"]),
_Field("port", "Порт HTTP:", "8007",
note="Порт FastAPI-сервера (по умолчанию 8007)"),
],
),
_Block(
yaml_key="planning",
title="MoveIt planning",
title="MoveIt планирование",
fields=[
_Field("planning_group", "Planning group:", "iiwa_arm",
note="MoveIt planning group as defined in the SRDF"),
_Field("default_frame", "Default reference frame:", "base_link"),
_Field("default_planner", "Default planner:", "ompl",
_Field("planning_group", "Группа планирования:", "iiwa_arm",
note="Группа планирования MoveIt из SRDF"),
_Field("default_frame", "Система отсчёта по умолчанию:", "base_link"),
_Field("default_planner", "Планировщик по умолчанию:", "ompl",
options=["ompl", "pilz_industrial_motion_planner", "chomp"]),
_Field("planning_attempts", "Planning attempts:", "3"),
_Field("planning_attempts", "Попыток планирования:", "3"),
],
),
_Block(
yaml_key="digital_twin",
title="Digital twin (Webots / RViz)",
title="Цифровой двойник (Webots / RViz)",
fields=[
_Field("webots.transform", "Robot transform in Webots scene (x y z, metres):", "-0.25 0 0.79"),
_Field("webots.rotation", "Robot rotation in Webots scene (ax ay az angle):", "0 0 1 0"),
_Field("webots.controller_timer", "Webots controller step timer (ms):", "50"),
_Field("webots.transform", "Трансформ робота в сцене Webots (x y z, метры):", "-0.25 0 0.79"),
_Field("webots.rotation", "Поворот робота в сцене Webots (ax ay az угол):", "0 0 1 0"),
_Field("webots.controller_timer", "Шаг таймера контроллера Webots (мс):", "50"),
],
),
_Block(
yaml_key="robot",
title="Robot connection",
title="Подключение робота",
fields=[
_Field("name", "Robot model name:", "iiwa7"),
_Field("ip", "Robot IP address:", "192.170.10.2",
note="IP of the KUKA controller on the FRI network interface"),
_Field("port", "FRI port:", "30200"),
_Field("command_mode", "Command mode:", "position",
note="position = joint position control, torque = joint torque control",
options=["position", "torque"]),
_Field("fri_cycle_ms", "FRI cycle time (ms):", "10",
note="5 ms = 200 Hz, 10 ms = 100 Hz",
_Field("name", "Имя модели робота:", "iiwa7"),
_Field("ip", "IP-адрес робота:", "192.170.10.2",
note="IP контроллера KUKA на сетевом интерфейсе FRI"),
_Field("port", "Порт FRI:", "30200"),
_Field("fri_cycle_ms", "Цикл FRI (мс):", "10",
note="5 мс = 200 Гц, 10 мс = 100 Гц",
options=["10", "5"]),
_Field("active_controller", "Active ROS controller:", "jtc",
_Field("active_controller", "Активный ROS-контроллер:", "jtc",
note="jtc = JointTrajectoryController (MoveIt), forward = ForwardCommandController",
options=["jtc", "forward"]),
_Field("joint_position_tau", "Position EMA filter tau (s):", "0.04",
note="Smooths position commands before sending to FRI"),
_Field("joint_velocity_tau", "Velocity EMA filter tau (s):", "0.01",
note="Removes spikes from finite-difference velocity estimation"),
_Field("joint_position_tau", "EMA tau фильтра положения (с):", "0.04",
note="Сглаживает команды положения перед отправкой в FRI"),
_Field("joint_velocity_tau", "EMA tau фильтра скорости (с):", "0.01",
note="Убирает выбросы из оценки скорости конечной разностью"),
],
),
]
# Try to keep the original YAML type (bool, int, float) when saving a value back.
# Trying to preserve type prevents "true" from becoming a plain string in the YAML file.
# Пытаемся сохранить исходный тип YAML (bool, int, float) при записи значения обратно.
# Сохранение типа предотвращает превращение "true" в обычную строку в YAML-файле.
def _coerce(value: str, original: Any) -> Any:
"""Convert a string value to match the type of the original YAML value (bool, int, float, str).
Преобразует строковое значение к типу исходного значения YAML (bool, int, float, str).
"""Convert a string value to match the type of the original YAML value.
Преобразует строковое значение к типу исходного значения YAML.
"""
if isinstance(original, bool):
return value.lower() == "true"
@@ -182,11 +174,9 @@ def _coerce(value: str, original: Any) -> Any:
return value
# Read a value from a nested YAML mapping using a dot-separated key like "webots.transform".
# Читаем значение из вложенного YAML-словаря по ключу с точками, например "webots.transform".
def _get_nested(mapping: Any, path: str) -> Any:
"""Return the value at a dot-separated path inside a nested YAML mapping, or None if missing.
Возвращает значение по пути с точками внутри вложенного YAML-словаря, или None если отсутствует.
"""Return the value at a dot-separated path inside a nested YAML mapping, or None.
Возвращает значение по пути с точками внутри вложенного YAML-словаря, или None.
"""
keys = path.split(".")
cur = mapping
@@ -197,172 +187,108 @@ def _get_nested(mapping: Any, path: str) -> Any:
return cur
# Write a value into a nested YAML mapping using a dot-separated key.
# Записываем значение в вложенный YAML-словарь по ключу с точками.
def _infer(value: str) -> Any:
"""Infer a bool / int / float / str from a raw string when there is no original
value to match the type against (i.e. the key is new in the config).
Выводит bool / int / float / str из строки, когда нет исходного значения для
сопоставления типа (т.е. ключ новый в конфиге).
"""
low = value.strip().lower()
if low in ("true", "false"):
return low == "true"
try:
return int(value)
except ValueError:
pass
try:
return float(value)
except ValueError:
pass
return value
def _set_nested(mapping: Any, path: str, value: Any) -> None:
"""Set the value at a dot-separated path inside a nested YAML mapping, coercing type to match.
Устанавливает значение по пути с точками во вложенном YAML-словаре, приводя тип к исходному.
"""Set the value at a dot-separated path, creating missing intermediate maps.
If the leaf key already exists its type is preserved via _coerce; otherwise the
type is inferred from the string with _infer. This makes the wizard tolerant of
configs that do not yet contain every field (e.g. an older cobot-setting.yaml).
Устанавливает значение по пути с точками, создавая отсутствующие промежуточные
словари. Если конечный ключ уже есть — тип сохраняется через _coerce; иначе тип
выводится из строки через _infer. Это делает мастер устойчивым к конфигам, где
ещё нет всех полей (например, более старый cobot-setting.yaml).
"""
keys = path.split(".")
cur = mapping
for k in keys[:-1]:
if k not in cur or cur[k] is None:
cur[k] = {}
cur = cur[k]
original = cur[keys[-1]]
cur[keys[-1]] = _coerce(value, original)
leaf = keys[-1]
if leaf in cur and cur[leaf] is not None:
cur[leaf] = _coerce(value, cur[leaf])
else:
cur[leaf] = _infer(value)
# Shown after all blocks have been configured to confirm the file was saved.
# Показывается после настройки всех блоков для подтверждения сохранения файла.
class _SavedScreen(Screen[None]):
"""Confirmation screen shown after all configuration blocks are saved. Press Enter to close.
Экран подтверждения, показываемый после сохранения всех блоков конфигурации. Enter для закрытия.
"""
BINDINGS = [Binding("enter,escape", "close", "Close")]
def compose(self) -> ComposeResult:
yield Static("Done", id="step")
yield Static(f"Configuration saved to {_CONFIG_PATH.name}", id="question")
yield Static("Press Enter to close.", id="note")
yield Footer()
def action_close(self) -> None:
self.dismiss(None)
# The main configuration wizard. Goes through each block in order.
# For each block it first asks "Configure X?" then steps through all its fields.
# Главный мастер конфигурации. Проходит по каждому блоку по порядку.
# Для каждого блока сначала спрашивает "Настроить X?" а затем проходит по всем его полям.
class _Wizard(App[None]):
"""Configuration wizard that iterates over all _BLOCKS. For each block it asks
"Configure X?" and if confirmed steps through every field with PickScreen or InputScreen.
Saves to cobot-setting.yaml when all blocks are done and shows _SavedScreen.
Мастер конфигурации, проходящий по всем _BLOCKS. Для каждого блока спрашивает
"Настроить X?" и при подтверждении проходит по всем полям через PickScreen или InputScreen.
Сохраняет в cobot-setting.yaml по завершении и показывает _SavedScreen.
"""
CSS = SCREEN_CSS
def __init__(self, data: Any, extra_blocks: Optional[List[_Block]] = None):
super().__init__()
self._data = data
self._blocks = list(extra_blocks or []) + list(_BLOCKS)
self._block_idx = 0
self._field_idx = 0
self._current_block: Optional[_Block] = None
self._pending_fields: List[_Field] = []
self.did_save = False
def on_mount(self) -> None:
self._next_block()
def _next_block(self) -> None:
if self._block_idx >= len(self._blocks):
# All blocks done - save and show the confirmation screen.
# Все блоки пройдены - сохраняем и показываем экран подтверждения.
_save_config(self._data)
self.did_save = True
self.push_screen(_SavedScreen(), lambda _: self.exit())
return
block = self._blocks[self._block_idx]
total = len(self._blocks)
step = f"Block {self._block_idx + 1} of {total}"
self.push_screen(
PickScreen(
step,
f"Configure {block.title}?",
["Yes", "No"],
"Yes",
),
lambda v: self._got_block_choice(v, block),
)
def _got_block_choice(self, v: Optional[str], block: _Block) -> None:
if v is None:
self.exit()
return
self._block_idx += 1
if v == "Yes":
self._current_block = block
self._pending_fields = list(block.fields)
self._field_idx = 0
self._next_field()
else:
# Skip all fields in this block and jump to the next block.
# Пропускаем все поля этого блока и переходим к следующему.
self._next_block()
def _next_field(self) -> None:
if not self._pending_fields:
self._next_block()
return
f = self._pending_fields[0]
block = self._current_block
total_blocks = len(self._blocks)
block_num = self._block_idx # already incremented
self._field_idx += 1
field_num = self._field_idx
total_fields = len(block.fields)
step = f"Block {block_num} of {total_blocks} - Field {field_num} of {total_fields}"
# Resolve current value from loaded YAML as the pre-filled default
yaml_val = _get_nested(self._data[block.yaml_key], f.key)
current = str(yaml_val) if yaml_val is not None else f.default
if f.options:
# Make the current value the default selection
default_opt = current if current in f.options else f.options[0]
screen = PickScreen(step, f.question, f.options, default_opt, note=f.note)
else:
screen = InputScreen(step, f.question, current, note=f.note)
self.push_screen(screen, lambda v, _f=f: self._got_field(v, _f))
def _got_field(self, v: Optional[str], f: _Field) -> None:
if v is None:
self.exit()
return
block = self._current_block
_set_nested(self._data[block.yaml_key], f.key, v)
# Remove the field we just handled and move on to the next one.
# Удаляем только что обработанное поле и переходим к следующему.
self._pending_fields.pop(0)
self._next_field()
# Load the config file preserving all comments and key order.
# Загружаем конфиг-файл, сохраняя все комментарии и порядок ключей.
def _load_config() -> Any:
"""Load cobot-setting.yaml with ruamel.yaml, preserving comments and key order.
Загружает cobot-setting.yaml с помощью ruamel.yaml, сохраняя комментарии и порядок ключей.
"""Load cobot-setting.yaml preserving comments and key order.
Загружает cobot-setting.yaml, сохраняя комментарии и порядок ключей.
"""
with open(_CONFIG_PATH, "r", encoding="utf-8") as fh:
return _yaml.load(fh)
# Write the modified config back to disk preserving comments and formatting.
# Записываем изменённый конфиг обратно на диск, сохраняя комментарии и форматирование.
def _save_config(data: Any) -> None:
"""Write the modified YAML data back to cobot-setting.yaml, preserving comments.
Записывает изменённые данные YAML обратно в cobot-setting.yaml, сохраняя комментарии.
"""Write the modified YAML back to cobot-setting.yaml, preserving comments.
Записывает изменённый YAML обратно в cobot-setting.yaml, сохраняя комментарии.
"""
with open(_CONFIG_PATH, "w", encoding="utf-8") as fh:
_yaml.dump(data, fh)
def _run_wizard(data: Any, blocks: List[_Block]) -> bool:
"""Walk every block: ask "Configure X?", and if yes step through its fields.
Returns True if the user completed the wizard (config was saved), False on cancel.
Проходит по каждому блоку: спрашивает "Настроить X?", и если да — проходит по полям.
Возвращает True если мастер завершён (конфиг сохранён), False при отмене.
"""
total = len(blocks)
for bi, block in enumerate(blocks):
ui.header(f"Блок {bi + 1}/{total}", block.title)
if not ui.confirm(f"Настроить {block.title}?", default=True):
continue
for fi, f in enumerate(block.fields):
yaml_val = _get_nested(data[block.yaml_key], f.key)
current = str(yaml_val) if yaml_val is not None else str(f.default)
step_note = f"Поле {fi + 1}/{len(block.fields)}"
note = f"{step_note}\n{f.note}" if f.note else step_note
if f.options:
default_opt = current if current in f.options else f.options[0]
value = ui.select(f.question, f.options, default_opt, note=note)
else:
value = ui.text(f.question, current, note=note)
if value is None:
ui.info("[yellow]Отменено.[/yellow]")
return False
_set_nested(data[block.yaml_key], f.key, value)
_save_config(data)
ui.done(True, f"Конфигурация сохранена в {_CONFIG_PATH.name}")
return True
def register(subparsers: argparse._SubParsersAction) -> None:
p = subparsers.add_parser("robot-setup", help="Configure cobot-setting.yaml interactively")
p.set_defaults(func=run)
def run(args: argparse.Namespace) -> None:
from rich.console import Console
console = Console()
ui.header("Настройка робота", "cobot-setting.yaml")
if not _CONFIG_PATH.exists():
console.print(f"[red]Config not found:[/red] {_CONFIG_PATH}")
ui.error(f"Конфиг не найден: {_CONFIG_PATH}")
sys.exit(1)
data = _load_config()
@@ -372,46 +298,37 @@ def run(args: argparse.Namespace) -> None:
data["tool"] = {"active": "patron"}
tool_block = _build_tool_block()
extra = [tool_block] if tool_block else []
blocks = ([tool_block] if tool_block else []) + list(_BLOCKS)
wizard = _Wizard(data, extra_blocks=extra)
wizard.run()
if not wizard.did_save:
if not _run_wizard(data, blocks):
return
# Применяем выбранный инструмент: перезаписываем tool_active.xacro и iiwa7.srdf
active_tool = str(data["tool"].get("active", "patron"))
if _TOOLS_YAML.exists():
try:
registry = _load_tools_registry()
if active_tool not in registry:
raise ValueError(
f"Unknown tool '{active_tool}'. Available: {', '.join(registry)}"
)
tool_cfg = dict(registry[active_tool])
if not _TOOLS_YAML.exists():
ui.info(f"[yellow]tools.yaml не найден ({_TOOLS_YAML}) — пропуск применения инструмента[/yellow]")
return
import sys as _sys
_sys.path.insert(0, str(_PROJECT_DIR / "src" / "iiwa_utils"))
from iiwa_utils.tool_manager import apply_tool
apply_tool(
tool_cfg=tool_cfg,
xacro_out_path=_TOOL_ACTIVE_XACRO,
srdf_path=_SRDF_PATH,
)
try:
registry = _load_tools_registry()
if active_tool not in registry:
raise ValueError(f"Неизвестный инструмент '{active_tool}'. Доступны: {', '.join(registry)}")
tool_cfg = dict(registry[active_tool])
# Синхронизируем planning.pose_link с tcp_link выбранного инструмента
tcp_link = tool_cfg.get("tcp_link", "link_ee")
if "planning" in data:
data["planning"]["pose_link"] = tcp_link
_save_config(data)
sys.path.insert(0, str(_PROJECT_DIR / "src" / "iiwa_utils"))
from iiwa_utils.tool_manager import apply_tool
apply_tool(tool_cfg=tool_cfg, xacro_out_path=_TOOL_ACTIVE_XACRO, srdf_path=_SRDF_PATH)
console.print(
f"[green]✓[/green] Tool [bold]{active_tool}[/bold] applied: "
f"tool_active.xacro, iiwa7.srdf updated, "
f"planning.pose_link → [bold]{tcp_link}[/bold]."
)
except Exception as exc:
console.print(f"[red]Tool apply failed:[/red] {exc}")
else:
console.print(f"[yellow]tools.yaml not found at {_TOOLS_YAML} — skipping tool apply[/yellow]")
# Синхронизируем planning.pose_link с tcp_link выбранного инструмента
tcp_link = tool_cfg.get("tcp_link", "link_ee")
if "planning" in data:
data["planning"]["pose_link"] = tcp_link
_save_config(data)
ui.info(
f"[green]✓[/green] Инструмент [bold]{active_tool}[/bold] применён: "
f"tool_active.xacro, iiwa7.srdf обновлены, "
f"planning.pose_link → [bold]{tcp_link}[/bold]."
)
except Exception as exc:
ui.error(f"Не удалось применить инструмент: {exc}")
+85 -319
View File
@@ -2,17 +2,21 @@ from __future__ import annotations
import argparse
import os
import signal
import shutil
import socket
import subprocess
from pathlib import Path
from typing import Callable, List, Optional
from typing import List, Optional
from textual.app import App
from cobot.tui import SCREEN_CSS, LogScreen, PickScreen, RunScreen
from cobot.commands.local_setup import webots_installed, WebotsInstallApp, _WEBOTS_VERSION
from cobot import process, ui
from cobot import privilege
from cobot.ui import done, header
from cobot.commands.local_setup import (
_WEBOTS_VERSION,
build_workspace,
install_webots,
webots_installed,
)
_PROJECT_DIR = Path(__file__).parent.parent.parent
_CONFIG_PATH = _PROJECT_DIR / "cobot-setting.yaml"
@@ -32,15 +36,11 @@ _CONTAINER_CONTROLLER = "lwc-controller"
_CONTAINER_WEBOTS = "lwc-webots"
# Named Docker volume that stores the Webots asset cache between container runs.
# Without it Webots re-downloads all 3D assets from the internet on every launch.
# Именованный Docker volume для хранения кэша ассетов Webots между запусками контейнера.
# Без него Webots заново скачивает все 3D-ассеты из интернета при каждом запуске.
_WEBOTS_CACHE_VOLUME = "lwc-webots-cache"
# Candidates checked in order - for the controller the webots image is a valid fallback
# because it already contains all controller packages too.
# Кандидаты проверяются по порядку - для контроллера образ webots является допустимым запасным,
# так как он уже содержит все пакеты контроллера.
# Candidates checked in order - for the controller the webots image is a valid fallback.
# Кандидаты проверяются по порядку - для контроллера образ webots является допустимым запасным.
_CONTROLLER_IMAGES = [
"lwc-local:ros-iiwa7-jazzy",
"evilfisru/lwc:iiwa-jazzy",
@@ -56,56 +56,16 @@ _WEBOTS_IMAGES = [
]
# A minimal app that asks one question and exits immediately with the chosen value.
# We need a full App because Textual screens cannot run outside one.
# Минимальное приложение, которое задаёт один вопрос и сразу выходит с выбранным значением.
# Нам нужен полноценный App, потому что экраны Textual не могут работать вне него.
class _Ask(App[Optional[str]]):
"""Minimal one-question Textual app. Pushes a PickScreen and exits with the chosen value.
Минимальное однвопросное Textual-приложение. Открывает PickScreen и завершается с выбранным значением.
"""
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(self._step, self._question, self._options, self._default),
self.exit,
)
def _ask(step: str, question: str, options: List[str], default: str) -> Optional[str]:
"""Show a single-choice PickScreen and return the selected value, or None on Escape.
Показывает PickScreen с одним выбором и возвращает выбранное значение или None при Escape.
"""
# Returns None when the user pressed Escape to cancel.
# Возвращает None когда пользователь нажал Escape для отмены.
return _Ask(step, question, options, default).run()
def _detect_webots_home() -> str:
"""Return the WEBOTS_HOME path for the locally installed Linux Webots.
Validates that the candidate directory contains the Linux 'webots' binary
Возвращает путь WEBOTS_HOME для локально установленного Linux Webots.
"""Return the WEBOTS_HOME path for the locally installed Linux Webots, or "".
Возвращает путь WEBOTS_HOME для локально установленного Linux Webots, или "".
"""
def _is_linux_webots(home: str) -> bool:
# Accept only directories that have the Linux 'webots' binary directly inside.
# Принимаем только директории с Linux-бинарником 'webots' напрямую внутри.
return (Path(home) / "webots").is_file()
if "WEBOTS_HOME" in os.environ:
home = os.environ["WEBOTS_HOME"]
if _is_linux_webots(home):
return home
return ""
return home if _is_linux_webots(home) else ""
if _WEBOTS_DEFAULT_HOME.is_dir() and _is_linux_webots(str(_WEBOTS_DEFAULT_HOME)):
return str(_WEBOTS_DEFAULT_HOME)
webots_bin = shutil.which("webots")
@@ -116,10 +76,8 @@ def _detect_webots_home() -> str:
return ""
# Detect the GPU type so we can pass the right flags to docker run for Webots rendering.
# Определяем тип GPU, чтобы передать нужные флаги в docker run для рендеринга Webots.
def _detect_gpu() -> str:
"""Return "nvidia", "mesa", or "software" based on what GPU drivers are available.
"""Return "nvidia", "mesa", or "software" based on available GPU drivers.
Возвращает "nvidia", "mesa" или "software" в зависимости от доступных драйверов GPU.
"""
if shutil.which("nvidia-smi"):
@@ -130,11 +88,9 @@ def _detect_gpu() -> str:
return "software"
# List all Docker images currently available on this machine.
# Получаем список всех Docker-образов доступных на этой машине.
def _docker_images() -> set:
"""Return the set of "repository:tag" strings for all locally available Docker images.
Возвращает множество строк "репозиторий:тег" для всех локально доступных Docker-образов.
"""Return the set of "repository:tag" strings for all local Docker images.
Возвращает множество строк "репозиторий:тег" для всех локальных Docker-образов.
"""
r = subprocess.run(
["docker", "images", "--format", "{{.Repository}}:{{.Tag}}"],
@@ -143,11 +99,9 @@ def _docker_images() -> set:
return set(r.stdout.strip().splitlines())
# Return the first image from the candidates list that is already present locally.
# Возвращаем первый образ из списка кандидатов, который уже присутствует локально.
def _find_image(candidates: List[str]) -> Optional[str]:
"""Return the first candidate image that exists locally, or None if none are available.
Возвращает первый образ-кандидат, присутствующий локально, или None если ни один не найден.
"""Return the first candidate image that exists locally, or None.
Возвращает первый образ-кандидат, присутствующий локально, или None.
"""
available = _docker_images()
for img in candidates:
@@ -156,82 +110,13 @@ def _find_image(candidates: List[str]) -> Optional[str]:
return None
# Build the ROS2 project locally with colcon. Used when launching in local mode
# and the install/ directory does not exist yet.
# Собираем ROS2-проект локально с помощью colcon. Используется при запуске в локальном режиме,
# если директория install/ ещё не существует.
def _task_build(screen: LogScreen) -> None:
"""Worker function that runs inside LogScreen. Counts packages, then runs colcon build
with release mixin and updates progress as each package finishes.
Рабочая функция внутри LogScreen. Подсчитывает пакеты, запускает colcon build с mixin release
и обновляет прогресс по мере завершения каждого пакета.
"""
try:
screen.write("[bold]Building project with colcon[/bold]\n")
# Count packages first so we can show X/total progress.
# Сначала считаем пакеты, чтобы показывать X/всего в прогрессе.
list_proc = subprocess.run(
["bash", "-c", f"source {_JAZZY_DIR}/setup.bash && colcon list"],
capture_output=True, text=True, cwd=_PROJECT_DIR,
)
total = max(len([l for l in list_proc.stdout.splitlines() if l.strip()]), 1)
screen.set_progress(0, f"0 / {total} packages done")
built = 0
proc = subprocess.Popen(
["bash", "-c",
f"source {_JAZZY_DIR}/setup.bash && colcon build --mixin release"],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, cwd=_PROJECT_DIR,
)
for line in proc.stdout:
s = line.rstrip()
if s:
screen.write(s)
# colcon prints "Finished <<<" or "Failed <<<" when each package is done.
# colcon печатает "Finished <<<" или "Failed <<<" когда каждый пакет готов.
if "Finished <<<" in line or "Failed <<<" in line:
built += 1
screen.set_progress(built / total * 100, f"{built} / {total} packages done")
proc.wait()
if proc.returncode != 0:
screen.write("\n[red]Build failed.[/red]")
screen.finish(False)
return
screen.set_progress(100, "Build complete")
screen.write("\n[green]Build successful.[/green]")
screen.finish(True)
except Exception as exc:
screen.write(f"\n[red]Error:[/red] {exc}")
screen.finish(False)
class _BuildApp(App[bool]):
"""Minimal app that opens a LogScreen running _task_build and exits with the build result.
Минимальное приложение, открывающее LogScreen с _task_build и завершающееся с результатом сборки.
"""
CSS = SCREEN_CSS
def on_mount(self) -> None:
self.push_screen(
LogScreen("Building project before launch", _task_build, show_progress=True),
self.exit,
)
# Start the ROS2 launch file directly on this machine without Docker.
# Uses start_new_session so we can kill the whole process group with one signal.
# Запускаем launch-файл ROS2 напрямую на этой машине без Docker.
# Используем start_new_session, чтобы можно было убить всю группу процессов одним сигналом.
def _task_run_local(screen: RunScreen, mode: str) -> None:
"""Worker function that runs inside RunScreen. Launches iiwa.launch.py locally by sourcing
ROS2 and install/setup.bash, then streams output until the process exits or is stopped.
Рабочая функция внутри RunScreen. Запускает iiwa.launch.py локально через source ROS2 и
install/setup.bash, затем транслирует вывод до завершения процесса или его остановки.
# Local (non-Docker) launch
# Локальный (не Docker) запуск
def _run_local(mode: str) -> None:
"""Launch iiwa.launch.py natively. The whole ros2 launch tree runs in its own
session so a single Ctrl-C tears down every node cleanly.
Запускает iiwa.launch.py нативно. Всё дерево ros2 launch работает в своей сессии,
поэтому один Ctrl-C аккуратно завершает каждый узел.
"""
config = str(_CONFIG_PATH)
ros_cmd = f"ros2 launch iiwa_bringup iiwa.launch.py setting:={config}"
@@ -248,51 +133,28 @@ def _task_run_local(screen: RunScreen, mode: str) -> None:
f"{ros_cmd}"
)
label = "Webots simulator" if mode == "webots" else "Controller"
screen.write(f"[bold]Launching {label} (local)[/bold]")
screen.write(f"[dim]{ros_cmd}[/dim]")
label = "симулятор Webots" if mode == "webots" else "контроллер"
header(f"Запуск: {label} (локально)")
ui.note(ros_cmd)
if webots_home:
screen.write(f"[dim]WEBOTS_HOME: {webots_home}[/dim]")
screen.write("")
ui.note(f"WEBOTS_HOME: {webots_home}")
ui.note("Нажмите Ctrl-C чтобы остановить")
proc = subprocess.Popen(
rc = process.stream(
["bash", "-c", full_cmd],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, cwd=_PROJECT_DIR,
start_new_session=True,
cwd=str(_PROJECT_DIR),
new_session=True,
)
screen.set_proc(proc)
# Kill the entire session so all ROS2 nodes are terminated together.
# ros2 launch puts each node in its own process group (setpgrp), so killpg on the
# bash pgid only reaches bash/launch itself. All nodes share the session started
# with start_new_session=True, so pkill -s reaches every one of them.
# Убиваем всю сессию, чтобы все ROS2-узлы завершились вместе.
# ros2 launch помещает каждый узел в отдельную группу процессов (setpgrp), поэтому
# killpg по pgid bash достигает только bash/launch. Все узлы разделяют сессию,
# созданную через start_new_session=True, поэтому pkill -s достигает каждого из них.
sid = os.getsid(proc.pid)
screen.set_kill_fn(lambda: subprocess.run(
["pkill", "-TERM", "-s", str(sid)], capture_output=True
))
for line in proc.stdout:
s = line.rstrip()
if s:
screen.write(s)
proc.wait()
screen.finish(stopped=screen._stopped)
done(rc in (0, -2, -15, 130), "Остановлено")
# Start the ROS2 launch file inside a Docker container.
# For Webots mode we also forward X11 and GPU access so the simulator window can appear on screen.
# Запускаем launch-файл ROS2 внутри Docker-контейнера.
# Для режима Webots также пробрасываем X11 и доступ к GPU, чтобы окно симулятора появилось на экране.
def _task_run_docker(screen: RunScreen, image: str, mode: str, gpu: str) -> None:
"""Worker function that runs inside RunScreen. Builds the docker run command with the
appropriate GPU/X11 flags for Webots, then streams container output until stopped or exited.
Рабочая функция внутри RunScreen. Формирует команду docker run с нужными флагами GPU/X11
для Webots, затем транслирует вывод контейнера до остановки или завершения.
# Docker launch
# Запуск в Docker
def _run_docker(image: str, mode: str, gpu: str) -> None:
"""Launch iiwa.launch.py inside a Docker container, forwarding X11/GPU for Webots.
The container is stopped with ``docker kill`` on Ctrl-C.
Запускает iiwa.launch.py внутри Docker-контейнера, пробрасывая X11/GPU для Webots.
Контейнер останавливается через ``docker kill`` по Ctrl-C.
"""
container = _CONTAINER_WEBOTS if mode == "webots" else _CONTAINER_CONTROLLER
@@ -303,8 +165,8 @@ def _task_run_docker(screen: RunScreen, image: str, mode: str, gpu: str) -> None
if mode == "webots":
ros_cmd += " simulate:=1"
# Remove any stale container with the same name left from a previous run.
# Удаляем устаревший контейнер с таким же именем, оставшийся от предыдущего запуска.
# Remove any stale container with the same name from a previous run.
# Удаляем устаревший контейнер с таким же именем от предыдущего запуска.
subprocess.run(["docker", "rm", "-f", container], capture_output=True)
cmd = [
@@ -316,15 +178,11 @@ def _task_run_docker(screen: RunScreen, image: str, mode: str, gpu: str) -> None
]
if mode == "webots":
# Allow the container to open windows on the host display.
# Разрешаем контейнеру открывать окна на дисплее хоста.
subprocess.run(["xhost", "+local:docker"], capture_output=True)
cmd += [
"-e", f"DISPLAY={os.environ.get('DISPLAY', ':0')}",
"-e", "QT_X11_NO_MITSHM=1",
"-v", "/tmp/.X11-unix:/tmp/.X11-unix:rw",
# Persist the Webots asset cache so it is not re-downloaded on every launch.
# Сохраняем кэш ассетов Webots, чтобы он не скачивался заново при каждом запуске.
"-v", f"{_WEBOTS_CACHE_VOLUME}:/root/.cache/Cyberbotics/Webots",
]
if gpu == "nvidia":
@@ -334,19 +192,13 @@ def _task_run_docker(screen: RunScreen, image: str, mode: str, gpu: str) -> None
"-e", "NVIDIA_DRIVER_CAPABILITIES=graphics,utility,compute",
]
elif gpu == "mesa":
# Pass through the DRI device for Intel/AMD hardware acceleration.
# Пробрасываем DRI-устройство для аппаратного ускорения Intel/AMD.
cmd += ["--device", "/dev/dri"]
else:
# No GPU found - fall back to software rendering via llvmpipe.
# GPU не найден - используем программный рендеринг через llvmpipe.
cmd += [
"-e", "LIBGL_ALWAYS_SOFTWARE=1",
"-e", "GALLIUM_DRIVER=llvmpipe",
]
# Mount the config file so the container uses our local cobot-setting.yaml.
# Монтируем конфиг-файл, чтобы контейнер использовал наш локальный cobot-setting.yaml.
if _CONFIG_PATH.exists():
cmd += ["-v", f"{_CONFIG_PATH}:{_CONFIG_IN_CONTAINER}:ro"]
@@ -355,170 +207,87 @@ def _task_run_docker(screen: RunScreen, image: str, mode: str, gpu: str) -> None
_GPU_LABELS = {
"nvidia": "NVIDIA GPU",
"mesa": "Intel/AMD DRI (Mesa)",
"software": "Software rendering (llvmpipe)",
"software": "Программный рендеринг (llvmpipe)",
}
label = "Webots simulator" if mode == "webots" else "Controller"
screen.write(f"[bold]Launching {label} in Docker[/bold]")
screen.write(f"[dim]Image: {image}[/dim]")
label = "симулятор Webots" if mode == "webots" else "контроллер"
header(f"Запуск: {label} в Docker")
ui.note(f"Образ: {image}")
if mode == "webots":
screen.write(f"[dim]GPU: {_GPU_LABELS.get(gpu, gpu)}[/dim]")
screen.write("")
ui.note(f"GPU: {_GPU_LABELS.get(gpu, gpu)}")
ui.note("Нажмите Ctrl-C чтобы остановить")
proc = subprocess.Popen(
rc = process.stream(
cmd,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True,
kill_fn=lambda: subprocess.run(["docker", "kill", container], capture_output=True),
)
screen.set_proc(proc)
# Use docker kill instead of proc.terminate() so the container is stopped immediately.
# Terminating only the docker CLI process leaves the container itself running.
# Используем docker kill вместо proc.terminate(), чтобы контейнер остановился немедленно.
# Завершение только процесса docker CLI оставляет сам контейнер работающим.
screen.set_kill_fn(lambda: subprocess.run(["docker", "kill", container], capture_output=True))
for line in proc.stdout:
s = line.rstrip()
if s:
screen.write(s)
proc.wait()
screen.finish(stopped=screen._stopped)
done(rc in (0, -2, -15, 130), "Остановлено")
# Wraps a RunScreen in an App so it can be launched with .run().
# Оборачивает RunScreen в App, чтобы его можно было запустить через .run().
class _RunApp(App[None]):
"""Minimal app that wraps a RunScreen so it can be started with .run().
Минимальное приложение, оборачивающее RunScreen чтобы его можно было запустить через .run().
"""
CSS = SCREEN_CSS
def __init__(self, title: str, task: Callable):
super().__init__()
self._title = title
self._run_fn = task
def on_mount(self) -> None:
self.push_screen(RunScreen(self._title, self._run_fn), lambda _: self.exit())
# Guide the user through launching locally - asks what to run, checks prerequisites,
# installs Webots and builds the project if needed, then launches.
# Ведёт пользователя через локальный запуск - спрашивает что запустить, проверяет
# предварительные условия, устанавливает Webots и собирает проект при необходимости, затем запускает.
def _local_flow(args: argparse.Namespace) -> None:
"""Interactive flow for local (non-Docker) launch. Checks Webots, ROS2, and build state,
offers to install/build missing pieces, then starts RunScreen.
Интерактивный сценарий для локального (не Docker) запуска. Проверяет Webots, ROS2 и состояние
сборки, предлагает установить/собрать недостающее, затем запускает RunScreen.
"""Interactive flow for local launch: check Webots/ROS2/build, then run.
Интерактивный сценарий локального запуска: проверка Webots/ROS2/сборки, затем запуск.
"""
mode_v = _ask(
"Run local",
"What do you want to launch?",
["Controller", "Webots simulator"],
"Controller",
mode_v = ui.select(
"Что запустить?",
["Контроллер", "Симулятор Webots"],
"Контроллер",
)
if mode_v is None:
return
mode = "webots" if mode_v == "Webots simulator" else "controller"
mode = "webots" if mode_v == "Симулятор Webots" else "controller"
# Check Webots installed (local mode only)
if mode == "webots" and not webots_installed():
v = _ask(
"Webots not found",
f"Webots {_WEBOTS_VERSION} is not installed. Install it now?",
[f"Yes, install Webots {_WEBOTS_VERSION}", "No, cancel"],
f"Yes, install Webots {_WEBOTS_VERSION}",
)
if v is None or v.startswith("No"):
if not ui.confirm(f"Webots {_WEBOTS_VERSION} не установлен. Установить сейчас?",
default=True):
return
ok = WebotsInstallApp().run()
if not ok:
if not privilege.ensure_sudo() or not install_webots():
return
# Check ROS2 Jazzy
if not _JAZZY_DIR.is_dir():
v = _ask(
"ROS2 not found",
"ROS2 Jazzy is not installed. Run local-setup now?",
["Yes, run local-setup", "No, cancel"],
"Yes, run local-setup",
)
if v and v.startswith("Yes"):
if ui.confirm("ROS2 Jazzy не установлен. Запустить local-setup?", default=True):
from cobot.commands.local_setup import run as _local_setup
_local_setup(args)
return
# Check project built
if not (_INSTALL_DIR / "setup.bash").exists():
v = _ask(
"Project not built",
"The project has not been built yet. Build it now?",
["Yes, build now", "No, cancel"],
"Yes, build now",
)
if v is None or v.startswith("No"):
if not ui.confirm("Проект ещё не собран. Собрать сейчас?", default=True):
return
ok = _BuildApp().run()
if not ok:
if not privilege.ensure_sudo() or not build_workspace():
return
label = "Webots simulator" if mode == "webots" else "Controller"
_RunApp(f"Running {label} — local", lambda s: _task_run_local(s, mode)).run()
_run_local(mode)
# Guide the user through launching in Docker - asks what to run, finds a suitable image,
# detects the GPU for Webots, and launches.
# Ведёт пользователя через запуск в Docker - спрашивает что запустить, ищет подходящий образ,
# определяет GPU для Webots и запускает.
def _docker_flow(args: argparse.Namespace) -> None:
"""Interactive flow for Docker launch. Finds the best available image, detects the GPU
for Webots mode, then starts RunScreen with the docker run task.
Интерактивный сценарий для запуска в Docker. Находит лучший доступный образ, определяет GPU
для режима Webots, затем запускает RunScreen с задачей docker run.
"""Interactive flow for Docker launch: pick an image, detect GPU, then run.
Интерактивный сценарий запуска в Docker: выбор образа, определение GPU, затем запуск.
"""
if not shutil.which("docker"):
from rich.console import Console
Console().print("[red]Error:[/red] Docker is not installed or not on PATH.")
ui.error("Docker не установлен или отсутствует в PATH.")
return
mode_v = _ask(
"Run in Docker",
"What do you want to launch?",
["Controller", "Webots simulator"],
"Controller",
mode_v = ui.select(
"Что запустить?",
["Контроллер", "Симулятор Webots"],
"Контроллер",
)
if mode_v is None:
return
mode = "webots" if mode_v == "Webots simulator" else "controller"
mode = "webots" if mode_v == "Симулятор Webots" else "controller"
candidates = _WEBOTS_IMAGES if mode == "webots" else _CONTROLLER_IMAGES
image = _find_image(candidates)
if image is None:
# No image available - offer to run docker-setup to get one.
# Образ не найден - предлагаем запустить docker-setup чтобы его получить.
what = "Webots" if mode == "webots" else "controller or Webots"
v = _ask(
"No image found",
f"No Docker image found for {what}. Run docker-setup now?",
["Yes, run docker-setup", "No, cancel"],
"Yes, run docker-setup",
)
if v and v.startswith("Yes"):
what = "Webots" if mode == "webots" else "контроллера или Webots"
if ui.confirm(f"Docker-образ для {what} не найден. Запустить docker-setup?",
default=True):
from cobot.commands.docker_setup import run as _docker_setup
_docker_setup(args)
return
# Only detect GPU for Webots - the controller does not need a display.
# GPU определяем только для Webots - контроллеру дисплей не нужен.
gpu = _detect_gpu() if mode == "webots" else "software"
label = "Webots simulator" if mode == "webots" else "Controller"
_RunApp(
f"Running {label} — Docker",
lambda s: _task_run_docker(s, image, mode, gpu),
).run()
_run_docker(image, mode, gpu)
def register(subparsers: argparse._SubParsersAction) -> None:
@@ -544,17 +313,14 @@ def run(args: argparse.Namespace) -> None:
elif mode == "docker":
_docker_flow(args)
else:
# No mode given - ask the user how they want to run.
# Режим не указан - спрашиваем пользователя как он хочет запустить.
v = _ask(
"Run",
"How do you want to run the project?",
["Local (native ROS2)", "Docker"],
"Local (native ROS2)",
v = ui.select(
"Как запустить проект?",
["Локально (нативный ROS2)", "Docker"],
"Локально (нативный ROS2)",
)
if v is None:
return
if v.startswith("Local"):
if v.startswith("Локально"):
_local_flow(args)
else:
_docker_flow(args)
+14 -59
View File
@@ -1,7 +1,6 @@
import argparse
from typing import List, Optional
from textual.app import App
from cobot import ui
# Import each sub-command's run() so we can call them in sequence.
# Импортируем run() каждой подкоманды, чтобы вызывать их по порядку.
@@ -9,40 +8,6 @@ from cobot.commands.doc_setup import run as _doc_setup
from cobot.commands.docker_setup import run as _docker_setup
from cobot.commands.local_setup import run as _local_setup
from cobot.commands.robot_setup import run as _robot_setup
from cobot.tui import SCREEN_CSS, PickScreen
# A minimal Textual app that asks a single question and exits with the chosen value.
# We need this because Textual screens cannot run outside of an App context.
# Минимальное Textual-приложение, которое задаёт один вопрос и выходит с выбранным значением.
# Нам это нужно, потому что экраны Textual не могут работать вне контекста приложения.
class _Ask(App[Optional[str]]):
"""Minimal one-question Textual app. Pushes a PickScreen and exits with the chosen value.
Минимальное однвопросное Textual-приложение. Открывает PickScreen и завершается с выбранным значением.
"""
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(self._step, self._question, self._options, self._default),
self.exit,
)
def _ask(step: str, question: str, options: List[str], default: str) -> Optional[str]:
"""Show a PickScreen and return the selected value, or None if the user pressed Escape.
Показывает PickScreen и возвращает выбранное значение или None если пользователь нажал Escape.
"""
# Returns None if the user pressed Escape to cancel the whole wizard.
# Возвращает None если пользователь нажал Escape для отмены всего мастера.
return _Ask(step, question, options, default).run()
def register(subparsers):
@@ -54,44 +19,34 @@ def register(subparsers):
def run(args: argparse.Namespace) -> None:
"""Run the three-step first-time setup wizard: doc server -> build env -> robot config.
Запускает трёхшаговый мастер первоначальной настройки: сервер документации -> среда сборки -> конфиг.
"""Run the three-step first-time setup wizard: docs -> build env -> robot config.
Запускает трёхшаговый мастер первичной настройки: документация -> среда сборки -> конфиг.
"""
ui.header("Первичная настройка", "3 шага")
# Step 1 - documentation server.
# Шаг 1 - сервер документации.
v = _ask("Step 1 of 3", "Set up the documentation server?", ["Yes", "No"], "Yes")
if v is None:
return
if v == "Yes":
if ui.confirm("Шаг 1/3 — настроить сервер документации?", default=True):
_doc_setup(args)
# Step 2 - build environment: local ROS2 or Docker.
# Шаг 2 - среда сборки: локальный ROS2 или Docker.
v = _ask(
"Step 2 of 3",
"How do you want to set up the build environment?",
env_choice = ui.select(
"Шаг 2/3 — как настроить среду сборки?",
[
"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 — установить ROS2 Jazzy на эту машину и собрать через colcon",
"docker-setup — собрать Docker-образ с предустановленным ROS2 Jazzy",
],
"local-setup — install ROS2 Jazzy on this machine and build with colcon",
"local-setup — установить ROS2 Jazzy на эту машину и собрать через colcon",
)
if v is None:
if env_choice is None:
return
if v.startswith("local"):
if env_choice.startswith("local"):
_local_setup(args)
else:
_docker_setup(args)
# Step 3 - robot parameters in cobot-setting.yaml.
# Шаг 3 - параметры робота в cobot-setting.yaml.
v = _ask(
"Step 3 of 3",
"Configure robot parameters (cobot-setting.yaml)?",
["Yes", "No"],
"Yes",
)
if v is None:
return
if v == "Yes":
if ui.confirm("Шаг 3/3 — настроить параметры робота (cobot-setting.yaml)?", default=True):
_robot_setup(args)
+55 -119
View File
@@ -4,134 +4,70 @@ import argparse
import subprocess
from pathlib import Path
from textual.app import App
from cobot.tui import SCREEN_CSS, LogScreen
from cobot import process, ui
from cobot.ui import done
from cobot.process import StepProgress
_PROJECT_DIR = Path(__file__).parent.parent.parent
# Pull the latest commits from the remote and reinstall the cobot CLI in one go.
# Progress bar: fetch (0-30%), pull (30-80%), reinstall (80-100%).
# Скачиваем последние коммиты с удалённого репозитория и переустанавливаем cobot CLI за один раз.
# Прогресс-бар: fetch (0-30%), pull (30-80%), переустановка (80-100%).
def _task_update(screen: LogScreen) -> None:
"""Worker function that runs inside LogScreen. Fetches the current branch, shows incoming
commits, pulls changes, then reinstalls the cobot CLI via uv tool install --editable.
Рабочая функция, выполняемая внутри LogScreen. Получает текущую ветку, показывает входящие
коммиты, вытягивает изменения, затем переустанавливает cobot CLI через uv tool install --editable.
def _git(*args: str) -> str:
"""Run a git command in the project dir and return its stripped stdout.
Запускает git-команду в директории проекта и возвращает обрезанный stdout.
"""
try:
# Find out which branch we are on so we can fetch and pull the right one.
# Определяем на какой ветке мы находимся, чтобы делать fetch и pull нужной ветки.
branch = subprocess.check_output(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=_PROJECT_DIR, text=True,
).strip()
screen.write(f"[cyan][*][/cyan] Branch: [bold]{branch}[/bold]")
# Fetch (0 → 30 %)
screen.set_progress(0, "Fetching from remote...")
screen.write("[cyan][*][/cyan] Fetching from remote...")
fetch_proc = subprocess.Popen(
["git", "fetch", "origin"],
cwd=_PROJECT_DIR, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
)
screen.set_proc(fetch_proc)
fetch_out, fetch_err = fetch_proc.communicate()
if screen.is_stopped():
return
if fetch_proc.returncode not in (0, -9):
screen.write(f"[red]Fetch failed:[/red] {fetch_err.strip()}")
screen.finish(False)
return
screen.set_progress(30)
# Count how many commits the remote is ahead of us.
# Считаем сколько коммитов нас опережает удалённый репозиторий.
behind = subprocess.check_output(
["git", "rev-list", f"HEAD..origin/{branch}", "--count"],
cwd=_PROJECT_DIR, text=True,
).strip()
if behind == "0":
if not screen.is_stopped():
screen.set_progress(100, "Already up to date")
screen.write("[green][ok][/green] Already up to date.")
screen.finish(True)
return
# Show which commits are coming in so the user knows what changed.
# Показываем какие коммиты приходят, чтобы пользователь знал что изменилось.
screen.write(f"\n[bold]{behind} new commit(s):[/bold]")
log_lines = subprocess.check_output(
["git", "log", f"HEAD..origin/{branch}", "--oneline"],
cwd=_PROJECT_DIR, text=True,
).strip().splitlines()
for line in log_lines:
screen.write(f" [dim]{line}[/dim]")
# Pull (30 → 80 %)
screen.set_progress(30, "Pulling changes...")
screen.write("\n[cyan][*][/cyan] Pulling changes...")
pull_proc = subprocess.Popen(
["git", "pull", "origin", branch],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, cwd=_PROJECT_DIR,
)
screen.set_proc(pull_proc)
pull_out, pull_err = pull_proc.communicate()
if screen.is_stopped():
return
if pull_proc.returncode not in (0, -9):
for line in (pull_out + pull_err).splitlines():
if line.strip():
screen.write(line)
screen.write("[red]Pull failed.[/red]")
screen.finish(False)
return
screen.set_progress(80)
# Reinstall (80 → 100 %)
# Reinstall so the cobot binary picks up any new dependencies from pyproject.toml.
# Переустанавливаем, чтобы бинарник cobot подхватил новые зависимости из pyproject.toml.
screen.set_progress(80, "Reinstalling cobot CLI...")
screen.write("\n[cyan][*][/cyan] Reinstalling cobot CLI...")
reinstall_proc = subprocess.Popen(
["uv", "tool", "install", "--editable", str(_PROJECT_DIR)],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
)
screen.set_proc(reinstall_proc)
reinstall_out, reinstall_err = reinstall_proc.communicate()
if screen.is_stopped():
return
if reinstall_proc.returncode in (0, -9):
screen.write("[green][ok][/green] cobot reinstalled")
else:
screen.write(f"[yellow]Warning:[/yellow] reinstall failed — {reinstall_err.strip()}")
if not screen.is_stopped():
screen.set_progress(100, "Done")
screen.write("\n[green]Project updated successfully.[/green]")
screen.finish(True)
except Exception as exc:
if not screen.is_stopped():
screen.write(f"\n[red]Error:[/red] {exc}")
screen.finish(False)
return subprocess.check_output(["git", *args], cwd=_PROJECT_DIR, text=True).strip()
class _UpdateApp(App[None]):
"""Minimal Textual app that opens a LogScreen running _task_update and exits when it closes.
Минимальное Textual-приложение, открывающее LogScreen с _task_update и завершающееся при закрытии.
def _update() -> None:
"""Fetch the current branch, show incoming commits, pull, and reinstall the cobot CLI.
Progress: fetch (0-30 %), pull (30-80 %), reinstall (80-100 %).
Получает текущую ветку, показывает входящие коммиты, делает pull и переустанавливает CLI.
Прогресс: fetch (0-30 %), pull (30-80 %), переустановка (80-100 %).
"""
ok, fail_msg = True, ""
with StepProgress("Обновление проекта") as p:
try:
branch = _git("rev-parse", "--abbrev-ref", "HEAD")
p.raw(f"[cyan]▸[/cyan] Ветка: [bold]{branch}[/bold]")
CSS = SCREEN_CSS
p.set(0, "Получение с удалённого репозитория...")
rc = process.stream(["git", "fetch", "origin"], cwd=str(_PROJECT_DIR), on_line=p.log)
if rc not in (0, -9, -15):
done(False, "git fetch завершился с ошибкой")
return
p.set(30)
def on_mount(self) -> None:
self.push_screen(
LogScreen("Updating project", _task_update, show_progress=True),
lambda _: self.exit(),
)
behind = _git("rev-list", f"HEAD..origin/{branch}", "--count")
if behind == "0":
p.set(100, "Уже актуально")
done(True, "Уже актуальная версия")
return
p.raw(f"\n[bold]{behind} новых коммит(ов):[/bold]")
for line in _git("log", f"HEAD..origin/{branch}", "--oneline").splitlines():
p.log(line)
p.set(30, "Применение изменений...")
rc = process.stream(["git", "pull", "origin", branch],
cwd=str(_PROJECT_DIR), on_line=p.log)
if rc not in (0, -9, -15):
done(False, "git pull завершился с ошибкой")
return
p.set(80)
p.set(80, "Переустановка cobot CLI...")
p.raw("\n[cyan]▸[/cyan] Переустановка cobot CLI...")
rc = process.stream(["uv", "tool", "install", "--editable", str(_PROJECT_DIR)],
on_line=p.log)
if rc in (0, -9, -15):
p.raw("[green]✓[/green] cobot переустановлен")
else:
p.raw("[yellow]Предупреждение:[/yellow] переустановка не удалась")
p.set(100, "Готово")
except subprocess.CalledProcessError as exc:
ok, fail_msg = False, str(exc)
done(ok, "Проект обновлён" if ok else fail_msg)
def register(subparsers: argparse._SubParsersAction) -> None:
@@ -140,4 +76,4 @@ def register(subparsers: argparse._SubParsersAction) -> None:
def run(args: argparse.Namespace) -> None:
_UpdateApp().run()
_update()
+176
View File
@@ -0,0 +1,176 @@
from __future__ import annotations
import subprocess
import sys
import threading
from typing import List, Optional, Sequence
from cobot import ui
from cobot.ui import console
# How often the keep-alive thread refreshes the sudo timestamp (seconds).
# Sudo's default timeout is 15 min; 60 s gives a huge safety margin.
# Как часто поток keep-alive обновляет токен sudo (секунды).
# Таймаут sudo по умолчанию 15 мин; 60 с даёт большой запас.
_KEEPALIVE_INTERVAL = 60
# Module-level state: whether sudo has been primed and the keep-alive thread.
# Состояние уровня модуля: прогрет ли sudo и поток keep-alive.
_primed = False
_keepalive_thread: Optional[threading.Thread] = None
_keepalive_stop = threading.Event()
def _have_valid_timestamp() -> bool:
"""Return True if a non-interactive ``sudo -n -v`` succeeds (cached token valid).
Возвращает True, если ``sudo -n -v`` проходит без запроса (токен закеширован и валиден).
"""
return subprocess.run(
["sudo", "-n", "-v"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
).returncode == 0
def _read_masked_password(prompt: str) -> Optional[str]:
"""Read a password character-by-character, echoing a ``★`` for each one.
Backspace deletes the last char. Enter submits. Ctrl-C / Esc cancels (None).
Falls back to getpass when stdin is not a TTY.
Читает пароль посимвольно, отображая ``★`` за каждый символ.
Backspace удаляет последний символ. Enter — подтвердить. Ctrl-C / Esc — отмена (None).
Откатывается на getpass, если stdin не является TTY.
"""
if not sys.stdin.isatty():
import getpass
try:
return getpass.getpass(prompt)
except (EOFError, KeyboardInterrupt):
return None
sys.stdout.write(prompt)
sys.stdout.flush()
chars: List[str] = []
while True:
kind, ch = ui._read_key()
if kind == "enter":
sys.stdout.write("\n")
sys.stdout.flush()
return "".join(chars)
if kind in ("esc", "interrupt"):
sys.stdout.write("\n")
sys.stdout.flush()
return None
if kind == "backspace":
if chars:
chars.pop()
# Erase one mask glyph: move back, overwrite with space, move back.
# Стираем один символ маски: назад, пробел, снова назад.
sys.stdout.write("\b \b")
sys.stdout.flush()
continue
# Space and any printable char are part of the password.
# Пробел и любой печатный символ — часть пароля.
if kind == "space":
chars.append(" ")
sys.stdout.write("")
sys.stdout.flush()
elif kind == "char" and ch.isprintable():
chars.append(ch)
sys.stdout.write("")
sys.stdout.flush()
def _validate_password(password: str) -> bool:
"""Feed the password to ``sudo -S -v`` to validate it and cache the timestamp.
Передаёт пароль в ``sudo -S -v`` для проверки и кеширования токена.
"""
proc = subprocess.run(
["sudo", "-S", "-v"],
input=password + "\n",
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
text=True,
)
return proc.returncode == 0
def _keepalive_loop() -> None:
"""Refresh the sudo timestamp periodically until the process exits.
Периодически обновляет токен sudo, пока процесс не завершится.
"""
while not _keepalive_stop.wait(_KEEPALIVE_INTERVAL):
subprocess.run(
["sudo", "-n", "-v"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
def _start_keepalive() -> None:
global _keepalive_thread
if _keepalive_thread is not None and _keepalive_thread.is_alive():
return
_keepalive_stop.clear()
_keepalive_thread = threading.Thread(target=_keepalive_loop, daemon=True)
_keepalive_thread.start()
def ensure_sudo() -> bool:
"""Make sure we hold a valid sudo timestamp, asking for the password once.
If a valid cached timestamp already exists (e.g. the user ran sudo recently),
no password is asked. Otherwise the user is prompted up to 3 times with a masked
input. On success a keep-alive thread is started. Returns True if sudo is ready.
Гарантирует наличие валидного токена sudo, спрашивая пароль один раз.
Если валидный токен уже есть (например, пользователь недавно вызывал sudo), пароль
не спрашивается. Иначе пользователю предлагается до 3 попыток с маскированным вводом.
При успехе запускается поток keep-alive. Возвращает True, если sudo готов.
"""
global _primed
if _primed and _have_valid_timestamp():
return True
if _have_valid_timestamp():
_primed = True
_start_keepalive()
return True
console.print(
"\n[bold]Для установки/удаления системных пакетов нужны права root.[/bold]"
)
console.print(
"[dim]Пароль спросим один раз и будем держать сессию sudo активной "
"до конца операции.[/dim]"
)
for attempt in range(3):
password = _read_masked_password(" [sudo] пароль: ")
if password is None:
console.print("[yellow]Отменено.[/yellow]")
return False
if _validate_password(password):
del password
_primed = True
_start_keepalive()
console.print("[green]✓ sudo активирован[/green]")
return True
remaining = 2 - attempt
if remaining > 0:
console.print(f"[red]Неверный пароль.[/red] Осталось попыток: {remaining}")
else:
console.print("[red]Неверный пароль. Превышено число попыток.[/red]")
return False
def sudo(cmd: Sequence[str]) -> List[str]:
"""Prefix a command with ``sudo -n`` (non-interactive; token already cached).
Префиксует команду ``sudo -n`` (неинтерактивно; токен уже закеширован).
"""
return ["sudo", "-n", *cmd]
def stop_keepalive() -> None:
"""Stop the keep-alive thread. Safe to call even if it was never started.
Останавливает поток keep-alive. Безопасно вызывать, даже если он не запускался.
"""
_keepalive_stop.set()
+332
View File
@@ -0,0 +1,332 @@
from __future__ import annotations
import atexit
import os
import signal
import subprocess
import threading
from typing import Callable, Dict, List, Optional, Sequence
from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn
from cobot.ui import console, done, header
# Type of an optional callback invoked for every streamed output line.
# Тип опционального колбэка, вызываемого для каждой строки потокового вывода.
LineHook = Callable[[str], None]
# Registry of all live subprocesses, so the signal handler can kill them on exit.
# Maps pid -> Popen. Guarded by a lock because procs start/finish in helper calls.
# Реестр всех живых подпроцессов, чтобы обработчик сигнала мог убить их при выходе.
# Сопоставляет pid -> Popen. Защищён блокировкой, т.к. процессы создаются/завершаются в хелперах.
_procs: Dict[int, subprocess.Popen] = {}
_procs_lock = threading.Lock()
_handlers_installed = False
def _register(proc: subprocess.Popen) -> None:
with _procs_lock:
_procs[proc.pid] = proc
def _unregister(proc: subprocess.Popen) -> None:
with _procs_lock:
_procs.pop(proc.pid, None)
def _kill_proc(proc: subprocess.Popen) -> None:
"""Terminate a process and everything it spawned.
Three strategies, in order of how the process was started:
* a custom kill_fn (e.g. ``docker kill <container>``) registered on the proc;
* a new-session process (e.g. ros2 launch) — every node shares the session, so
``pkill -s <sid>`` reaches all of them (killpg would only hit the launcher);
* otherwise the process group (SIGTERM then SIGKILL), or the bare process.
Завершает процесс и всё, что он породил. Три стратегии по способу запуска:
пользовательский kill_fn (например ``docker kill``); процесс в новой сессии
(ros2 launch — все узлы делят сессию, поэтому ``pkill -s`` достаёт каждый);
иначе группа процессов (SIGTERM→SIGKILL) или сам процесс.
"""
if proc.poll() is not None:
return
kill_fn = getattr(proc, "_cobot_kill_fn", None)
if kill_fn is not None:
try:
kill_fn()
try:
proc.wait(timeout=5)
return
except subprocess.TimeoutExpired:
pass
except Exception:
pass
if getattr(proc, "_cobot_new_session", False):
try:
sid = os.getsid(proc.pid)
subprocess.run(["pkill", "-TERM", "-s", str(sid)],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try:
proc.wait(timeout=3)
return
except subprocess.TimeoutExpired:
subprocess.run(["pkill", "-KILL", "-s", str(sid)],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return
except Exception:
pass
try:
pgid = os.getpgid(proc.pid)
os.killpg(pgid, signal.SIGTERM)
try:
proc.wait(timeout=3)
except subprocess.TimeoutExpired:
os.killpg(pgid, signal.SIGKILL)
except Exception:
try:
proc.terminate()
except Exception:
pass
def kill_all() -> None:
"""Kill every registered subprocess. Used by the signal handler and atexit.
Убивает каждый зарегистрированный подпроцесс. Используется обработчиком сигнала и atexit.
"""
with _procs_lock:
procs = list(_procs.values())
for proc in procs:
_kill_proc(proc)
def _on_sigint(signum, frame): # noqa: ANN001
"""SIGINT handler: stop all children, print a cancel note, and exit non-zero.
Обработчик SIGINT: останавливает всех потомков, печатает заметку об отмене и выходит с ненулём.
"""
kill_all()
console.print("\n[yellow]Прервано пользователем (Ctrl-C).[/yellow]")
raise SystemExit(130)
def install_signal_handlers() -> None:
"""Install the SIGINT handler and atexit cleanup exactly once.
Устанавливает обработчик SIGINT и очистку atexit ровно один раз.
"""
global _handlers_installed
if _handlers_installed:
return
_handlers_installed = True
signal.signal(signal.SIGINT, _on_sigint)
atexit.register(kill_all)
def spawn(
cmd: Sequence[str],
*,
env: Optional[dict] = None,
cwd: Optional[str] = None,
new_session: bool = False,
shell: bool = False,
kill_fn: Optional[Callable] = None,
) -> subprocess.Popen:
"""Start a subprocess with merged stdout/stderr as text, register it, and return it.
new_session=True puts the process in its own session/process-group so the whole
tree (e.g. all ros2 launch nodes) can be torn down with one signal. kill_fn is an
optional custom teardown (e.g. ``docker kill``) used by the cleanup logic.
Запускает подпроцесс с объединённым stdout/stderr в текстовом режиме, регистрирует
его и возвращает. new_session=True помещает процесс в собственную сессию/группу.
kill_fn — опциональная функция завершения (например ``docker kill``) для очистки.
"""
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
env=env,
cwd=cwd,
start_new_session=new_session,
shell=shell,
)
proc._cobot_new_session = new_session # type: ignore[attr-defined]
proc._cobot_kill_fn = kill_fn # type: ignore[attr-defined]
_register(proc)
return proc
def stream(
cmd: Sequence[str],
*,
env: Optional[dict] = None,
cwd: Optional[str] = None,
on_line: Optional[LineHook] = None,
new_session: bool = False,
shell: bool = False,
echo: bool = True,
kill_fn: Optional[Callable] = None,
) -> int:
"""Run a command and stream every output line to the console (and on_line hook).
Returns the process exit code. SIGKILL (-9) / SIGTERM (-15) are returned as-is so
callers can treat user cancellation differently from real failures.
Запускает команду и транслирует каждую строку вывода в консоль (и в колбэк on_line).
Возвращает код возврата процесса. SIGKILL (-9) / SIGTERM (-15) возвращаются как есть,
чтобы вызывающий код мог отличать отмену пользователем от реальных ошибок.
"""
proc = spawn(cmd, env=env, cwd=cwd, new_session=new_session, shell=shell, kill_fn=kill_fn)
try:
for line in proc.stdout:
s = line.rstrip()
if on_line is not None:
on_line(s)
elif echo and s:
console.print(f" [dim]{_escape(s)}[/dim]")
proc.wait()
finally:
_unregister(proc)
return proc.returncode
def _escape(s: str) -> str:
"""Escape Rich markup so raw command output is never interpreted as markup.
Экранирует разметку Rich, чтобы сырой вывод команды не интерпретировался как разметка.
"""
return s.replace("[", "\\[")
# A progress bar that sticks to the bottom while log lines scroll above it.
# Прогресс-бар, "прилипающий" к низу, пока строки лога прокручиваются над ним.
def make_progress() -> Progress:
"""Create a Progress with a spinner, bar, percentage, and description column.
Создаёт Progress со спиннером, баром, процентами и колонкой описания.
"""
return Progress(
SpinnerColumn(),
BarColumn(bar_width=30),
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
TextColumn("[dim]{task.description}[/dim]"),
console=console,
transient=True,
)
def run_step(
title: str,
cmd: Sequence[str],
*,
env: Optional[dict] = None,
cwd: Optional[str] = None,
new_session: bool = False,
shell: bool = False,
show_progress: bool = True,
total: float = 100.0,
parse_progress: Optional[Callable[[str], Optional[tuple]]] = None,
on_line: Optional[LineHook] = None,
success_msg: str = "",
fail_msg: str = "",
finish: bool = True,
) -> int:
"""Run one command as a self-contained "block": header, live log + progress, status.
parse_progress(line) may return (pct, label) to advance the bar, or None to ignore.
Returns the exit code. Prints a ✓/✗ line unless finish=False (used when chaining
several commands under one header).
Запускает одну команду как самодостаточный "блок": заголовок, живой лог + прогресс,
статус. parse_progress(line) может вернуть (pct, label) для продвижения бара или None.
Возвращает код возврата. Печатает строку ✓/✗, если finish=True (иначе — при цепочке
нескольких команд под одним заголовком).
"""
if title:
header(title)
if not show_progress:
rc = stream(cmd, env=env, cwd=cwd, on_line=on_line,
new_session=new_session, shell=shell)
else:
progress = make_progress()
with progress:
task = progress.add_task("", total=total)
def _line(s: str) -> None:
if parse_progress is not None:
parsed = parse_progress(s)
if parsed is not None:
pct, label = parsed
progress.update(task, completed=pct,
description=label or "")
if on_line is not None:
on_line(s)
elif s:
progress.console.print(f" [dim]{_escape(s)}[/dim]")
rc = stream(cmd, env=env, cwd=cwd, on_line=_line,
new_session=new_session, shell=shell)
progress.update(task, completed=total)
ok = rc in (0, -9, -15)
if finish:
if ok:
done(True, success_msg or "Готово")
else:
done(False, fail_msg or f"Команда завершилась с кодом {rc}")
return rc
# A live progress context for tasks that run several commands or Python work and
# need to drive the bar manually. Yields a small controller with .log()/.set().
# Живой контекст прогресса для задач, выполняющих несколько команд или Python-работу
# и управляющих баром вручную. Отдаёт небольшой контроллер с .log()/.set().
class StepProgress:
"""Manual progress controller used as a context manager.
Usage:
with StepProgress("Building") as p:
p.set(10, "step one")
p.log("some output")
Ручной контроллер прогресса, используемый как менеджер контекста.
"""
def __init__(self, title: str, total: float = 100.0, show: bool = True):
if title:
header(title)
self._total = total
self._show = show
self._progress: Optional[Progress] = None
self._task = None
def __enter__(self) -> "StepProgress":
if self._show:
self._progress = make_progress()
self._progress.__enter__()
self._task = self._progress.add_task("", total=self._total)
return self
def set(self, pct: float, label: str = "") -> None:
if self._progress is not None:
self._progress.update(self._task, completed=pct, description=label or "")
def log(self, line: str, style: str = "dim") -> None:
out = self._progress.console if self._progress is not None else console
if line == "":
out.print()
else:
out.print(f" [{style}]{_escape(line)}[/{style}]" if style else f" {line}")
def raw(self, renderable) -> None:
"""Print a pre-built Rich renderable/markup string without escaping.
Печатает готовый Rich-объект/строку с разметкой без экранирования.
"""
out = self._progress.console if self._progress is not None else console
out.print(renderable)
def __exit__(self, exc_type, exc, tb) -> None:
if self._progress is not None:
self._progress.__exit__(exc_type, exc, tb)
self._progress = None
-463
View File
@@ -1,463 +0,0 @@
from __future__ import annotations
import os
import signal
from typing import Callable, List, Optional
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, ProgressBar, RadioButton, RadioSet, RichLog, SelectionList, Static
from textual.widgets.selection_list import Selection
# Shared CSS applied to every screen in the app.
# Общий CSS, применяемый ко всем экранам приложения.
SCREEN_CSS = """
Screen {
padding: 2 4;
}
#step {
color: $text-muted;
text-style: dim;
}
#question {
text-style: bold;
color: $accent;
margin-top: 1;
margin-bottom: 1;
}
#note {
color: $text-muted;
text-style: dim;
margin-bottom: 1;
}
RadioSet {
height: auto;
border: none;
padding: 0;
margin-bottom: 1;
}
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;
padding: 0 1;
margin-top: 1;
}
LogScreen #loading {
height: 1;
margin-top: 1;
}
LogScreen #hint {
margin-top: 1;
color: $text;
}
RunScreen #log {
height: 1fr;
border: none;
padding: 0 1;
margin-top: 1;
}
RunScreen #loading {
height: 1;
margin-top: 1;
}
RunScreen #hint {
margin-top: 1;
color: $text-muted;
text-style: dim;
}
MultiPickScreen SelectionList {
height: auto;
border: none;
padding: 0;
margin-bottom: 1;
}
"""
# A screen that shows a question and a list of radio button options.
# The user picks one and presses Enter - the chosen string is returned as the result.
# Экран с вопросом и списком вариантов в виде радио-кнопок.
# Пользователь выбирает один и нажимает Enter - выбранная строка возвращается как результат.
class PickScreen(Screen[Optional[str]]):
"""Single-choice radio button screen. Returns the selected option string, or None on Escape.
Экран выбора одного варианта с радио-кнопками. Возвращает выбранную строку или None при Escape.
"""
BINDINGS = [
Binding("enter", "submit", "Confirm", priority=True),
Binding("escape", "abort", "Cancel"),
]
def __init__(self, step: str, question: str, options: List[str], default: str, note: str = ""):
super().__init__()
self._step = step
self._question = question
self._options = options
self._default = default
self._note = note
def compose(self) -> ComposeResult:
yield Static(self._step, id="step")
yield Static(self._question, id="question")
if self._note:
yield Static(self._note, id="note")
with RadioSet(id="choices"):
for opt in self._options:
# Pre-select the default option so the user can just press Enter to accept it.
# Заранее выделяем вариант по умолчанию, чтобы пользователь мог просто нажать Enter.
yield RadioButton(opt, value=(opt == self._default))
yield Footer()
def on_mount(self) -> None:
self.query_one(RadioSet).focus()
def action_submit(self) -> None:
radio_set = self.query_one("#choices", RadioSet)
buttons = list(radio_set.query(RadioButton))
idx = getattr(radio_set, "_selected", None)
if idx is not None and 0 <= idx < len(buttons):
self.dismiss(str(buttons[idx].label))
else:
btn = radio_set.pressed_button
self.dismiss(str(btn.label) if btn else self._default)
def action_abort(self) -> None:
# Exit the whole app, not just this screen, so the calling code knows the user cancelled.
# Выходим из всего приложения, а не только из этого экрана, чтобы вызывающий код знал об отмене.
self.app.exit(None)
# A screen that shows a question with a free-text input field.
# The user types a value, presses Enter, and the text is returned as the result.
# Экран с вопросом и полем для ввода произвольного текста.
# Пользователь вводит значение, нажимает Enter, и текст возвращается как результат.
class InputScreen(Screen[Optional[str]]):
"""Free-text input screen. Returns the trimmed value on Enter, or None on Escape.
Экран свободного ввода текста. Возвращает обрезанное значение при Enter или None при Escape.
"""
BINDINGS = [
Binding("enter", "submit", "Confirm", priority=True),
Binding("escape", "abort", "Cancel"),
]
def __init__(self, step: str, question: str, default: str, note: str = ""):
super().__init__()
self._step = step
self._question = question
self._default = default
self._note = note
def compose(self) -> ComposeResult:
yield Static(self._step, id="step")
yield Static(self._question, id="question")
if self._note:
yield Static(self._note, id="note")
yield Input(id="value", value=self._default)
yield Footer()
def on_mount(self) -> None:
self.query_one(Input).focus()
@on(Input.Submitted)
def _submitted(self, event: Input.Submitted) -> None:
self.dismiss(event.value.strip() or self._default)
def action_submit(self) -> None:
val = self.query_one(Input).value.strip()
self.dismiss(val or self._default)
def action_abort(self) -> None:
self.app.exit(None)
# A screen that streams output from a background task into a scrollable log.
# Used for long-running operations like installs and builds.
# Press Enter or Escape to close once the task finishes.
# Экран, который транслирует вывод фоновой задачи в прокручиваемый лог.
# Используется для долгих операций, таких как установка и сборка.
# После завершения задачи закрывается по нажатию Enter или Escape.
class LogScreen(Screen[bool]):
"""Log screen for long-running background tasks. Shows a scrollable log and optional
progress bar. Returns True on success, False on failure after the task finishes.
Экран лога для долгих фоновых задач. Показывает прокручиваемый лог и опциональный
прогресс-бар. Возвращает True при успехе, False при ошибке после завершения задачи.
"""
BINDINGS = [Binding("enter,escape", "close", "Close", show=False)]
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._success = False
self._show_progress = show_progress
# All subprocesses registered via set_proc() - every one gets killed on unmount.
# Все подпроцессы зарегистрированные через set_proc() - каждый убивается при выходе.
self._active_proc = None
self._procs: list = []
self._stopped = False
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")
yield Footer()
def on_mount(self) -> None:
self.query_one(RichLog).focus()
# Run the task in a worker thread so the UI stays responsive.
# Запускаем задачу в отдельном потоке, чтобы интерфейс не зависал.
self.app.run_worker(lambda: self._run_fn(self), thread=True)
def set_proc(self, proc) -> None:
# Register the subprocess that is currently running. Added to _procs so on_unmount
# can kill it even if another proc is registered afterwards.
# Регистрируем текущий subprocess. Добавляем в _procs, чтобы on_unmount мог его убить
# даже если после него будет зарегистрирован другой процесс.
self._active_proc = proc
self._procs.append(proc)
def is_stopped(self) -> bool:
# Return True if the user has closed the screen before the task finished.
# Возвращает True если пользователь закрыл экран до завершения задачи.
return self._stopped
def on_unmount(self) -> None:
# Kill every registered subprocess so nothing keeps running in the background after exit.
# Use SIGKILL on the process group to also terminate any children spawned by the process
# (e.g. dpkg or apt subprocesses spawned under sudo). Falls back to proc.kill() if the
# process group is not available (e.g. already exited).
# Убиваем все зарегистрированные подпроцессы, чтобы ничего не висело в фоне после выхода.
# Используем SIGKILL по группе процессов, чтобы завершить и дочерние процессы
# (например dpkg или apt запущенные под sudo). Откат на proc.kill() если группа недоступна.
self._stopped = True
for proc in list(self._procs):
try:
pgid = os.getpgid(proc.pid)
os.killpg(pgid, signal.SIGKILL)
except Exception:
try:
proc.kill()
except Exception:
pass
self._procs.clear()
def set_progress(self, pct: float, label: str = "") -> None:
# Thread-safe - this is called from the worker thread, not the UI thread.
# Потокобезопасно - вызывается из рабочего потока, а не из потока интерфейса.
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 from a worker thread.
# Потокобезопасно - добавляет строку в лог из рабочего потока.
self.app.call_from_thread(self._append, line)
def _append(self, line: str) -> None:
self.query_one(RichLog).write(line)
def finish(self, success: bool) -> None:
# Thread-safe - called by the task when it is done to show the close hint.
# Потокобезопасно - вызывается задачей по завершении, чтобы показать подсказку о закрытии.
self.app.call_from_thread(self._do_finish, success)
def _do_finish(self, success: bool) -> None:
self._finished = True
self._success = success
self.query_one("#loading", LoadingIndicator).display = False
msg = (
"[green]Done![/green] Press [bold]Enter[/bold] to close."
if success
else "[red]Failed.[/red] Press [bold]Enter[/bold] to close."
)
self.query_one("#hint", Static).update(msg)
def action_close(self) -> None:
# Only allow closing after the task has finished, not while it is still running.
# Разрешаем закрытие только после завершения задачи, а не во время её работы.
if self._finished:
self.dismiss(self._success)
# A screen that shows a list of checkboxes for multi-selection.
# The user toggles items with Space, confirms with Enter, cancels with Escape.
# Экран с чекбоксами для множественного выбора.
# Пользователь переключает пункты пробелом, подтверждает Enter, отменяет Escape.
class MultiPickScreen(Screen[Optional[List[str]]]):
"""Multi-choice screen using SelectionList. Navigate with arrows, toggle with Space,
confirm with Enter, cancel with Escape. Returns selected option strings or None.
Экран множественного выбора через SelectionList. Стрелки — навигация, пробел — выбор,
Enter — подтверждение, Escape — отмена. Возвращает выбранные строки или None.
"""
BINDINGS = [
Binding("enter", "submit", "Confirm", priority=True),
Binding("escape", "abort", "Cancel"),
]
def __init__(self, step: str, question: str, options: List[str],
defaults: Optional[List[str]] = None, note: str = ""):
super().__init__()
self._step = step
self._question = question
self._options = options
# All options are selected by default when defaults is None.
# Все пункты выбраны по умолчанию если defaults не передан.
self._defaults = set(defaults) if defaults is not None else set(options)
self._note = note
def compose(self) -> ComposeResult:
yield Static(self._step, id="step")
yield Static(self._question, id="question")
if self._note:
yield Static(self._note, id="note")
yield SelectionList(
*[Selection(opt, opt, opt in self._defaults) for opt in self._options]
)
yield Footer()
def on_mount(self) -> None:
self.query_one(SelectionList).focus()
def action_submit(self) -> None:
self.dismiss(list(self.query_one(SelectionList).selected))
def action_abort(self) -> None:
self.app.exit(None)
# A screen for a long-running process that the user can stop at any time.
# Shows a live log and offers S / Enter / Escape to stop or close.
# Экран для долго работающего процесса, который пользователь может остановить в любой момент.
# Показывает живой лог и предлагает S / Enter / Escape для остановки или закрытия.
class RunScreen(Screen[None]):
"""Run screen for a persistent process (e.g. ROS2 launch). Shows a live log and allows
the user to stop the process with S or close after it exits with Enter/Escape.
Экран запуска для постоянно работающего процесса (например ros2 launch). Показывает живой
лог и позволяет остановить процесс клавишей S или закрыть после завершения через Enter/Escape.
"""
BINDINGS = [
Binding("s", "stop_close", "Stop", show=True, priority=True),
Binding("enter", "stop_close", "Close", show=False),
Binding("escape", "stop_close", "Close", show=False),
]
def __init__(self, title: str, task: Callable[[RunScreen], None]):
super().__init__()
self._title = title
self._run_fn = task
self._proc = None # the subprocess, set via set_proc()
self._kill_fn = None # optional custom kill callable, set via set_kill_fn()
self._finished = False
self._stopped = False
self._procs: list = [] # all registered procs for cleanup on forced exit
def compose(self) -> ComposeResult:
yield Static(self._title, id="step")
yield RichLog(id="log", highlight=True, markup=True, wrap=True)
yield LoadingIndicator(id="loading")
yield Static(" Press [bold]S[/bold] to stop the process", id="hint")
yield Footer()
def on_mount(self) -> None:
self.query_one(RichLog).focus()
# Run the process task in a worker thread so the UI stays responsive.
# Запускаем задачу с процессом в отдельном потоке, чтобы интерфейс не зависал.
self.app.run_worker(lambda: self._run_fn(self), thread=True)
def set_proc(self, proc) -> None:
# Register the subprocess so the Stop button knows what to terminate.
# Регистрируем subprocess, чтобы кнопка Stop знала что завершать.
self._proc = proc
self._procs.append(proc)
def set_kill_fn(self, fn: Callable) -> None:
# Override the default proc.terminate() with a custom kill function.
# For example, docker kill or os.killpg for process groups.
# Заменяем стандартный proc.terminate() кастомной функцией завершения.
# Например, docker kill или os.killpg для групп процессов.
self._kill_fn = fn
def write(self, line: str) -> None:
# Thread-safe - called from the worker thread to append a log line.
# Потокобезопасно - вызывается из рабочего потока для добавления строки в лог.
self.app.call_from_thread(self._append, line)
def _append(self, line: str) -> None:
self.query_one(RichLog).write(line)
def finish(self, stopped: bool = False) -> None:
# Thread-safe - called by the task when the process exits naturally.
# Потокобезопасно - вызывается задачей когда процесс завершается естественным образом.
self.app.call_from_thread(self._do_finish, stopped)
def _do_finish(self, stopped: bool) -> None:
self._finished = True
self.query_one("#loading", LoadingIndicator).display = False
if stopped:
msg = "[yellow]Process stopped.[/yellow] Press [bold]Enter[/bold] to close."
else:
msg = "[green]Process exited.[/green] Press [bold]Enter[/bold] to close."
self.query_one("#hint", Static).update(msg)
def on_unmount(self) -> None:
# Kill all registered subprocesses when the screen is forcibly closed (e.g. Ctrl+Q).
# Убиваем все зарегистрированные подпроцессы при принудительном закрытии экрана (Ctrl+Q).
self._stopped = True
if self._kill_fn is not None:
try:
self._kill_fn()
except Exception:
pass
for proc in list(self._procs):
try:
pgid = os.getpgid(proc.pid)
os.killpg(pgid, signal.SIGKILL)
except Exception:
try:
proc.kill()
except Exception:
pass
self._procs.clear()
def action_stop_close(self) -> None:
# This runs in the UI thread, so we call _append() directly instead of write()
# because write() uses call_from_thread() which only works from other threads.
# Выполняется в потоке UI, поэтому вызываем _append() напрямую, а не write(),
# потому что write() использует call_from_thread(), который работает только из других потоков.
if self._finished:
self.dismiss(None)
return
self._stopped = True
if self._kill_fn is not None:
try:
self._kill_fn()
except Exception:
pass
elif self._proc is not None and self._proc.poll() is None:
try:
self._proc.terminate()
except Exception:
pass
self._append("\n[yellow]Stopping process...[/yellow]")
+358
View File
@@ -0,0 +1,358 @@
from __future__ import annotations
import os
import select as _select
import sys
from typing import List, Optional, Sequence, Tuple
from rich.console import Console, Group
from rich.live import Live
from rich.panel import Panel
from rich.text import Text
# Raw terminal control is POSIX-only; the project targets Linux/ROS so this is fine.
# Сырой режим терминала только для POSIX; проект под Linux/ROS, так что всё в порядке.
try:
import termios
import tty
_HAS_TERMIOS = True
except ImportError: # pragma: no cover - Windows fallback
_HAS_TERMIOS = False
# Single shared console used everywhere so styling and width stay consistent.
# Единый общий console, используемый везде, чтобы стиль и ширина были согласованы.
console = Console(highlight=False)
# Glyphs used across the UI. Kept here so the whole look can be retuned in one place.
# Глифы, используемые в интерфейсе. Собраны здесь, чтобы весь вид настраивался в одном месте.
_CURSOR = ""
_OK = ""
_FAIL = ""
_CHECK_ON = ""
_CHECK_OFF = ""
def is_interactive() -> bool:
"""Return True if both stdin and stdout are real terminals.
Arrow-key selection needs a real TTY to read raw key presses. When that is
not available (piped input, CI) callers should fall back to defaults.
Возвращает True, если и stdin, и stdout являются настоящими терминалами.
Выбор стрелками требует реального TTY для чтения нажатий клавиш. Если его нет
(перенаправленный ввод, CI), вызывающий код должен использовать значения по умолчанию.
"""
try:
return sys.stdin.isatty() and sys.stdout.isatty()
except Exception:
return False
# Low-level key reader
# Низкоуровневое чтение клавиш
# How long to wait (seconds) after a lone ESC byte before deciding it is really the
# Escape key and not the start of an arrow escape sequence (\x1b[A etc.).
# Сколько ждать (секунд) после одиночного байта ESC, прежде чем решить, что это
# именно клавиша Escape, а не начало escape-последовательности стрелок (\x1b[A и т.п.).
_ESC_TIMEOUT = 0.05
def _read_key() -> Tuple[str, str]:
"""Read one key press in raw mode and classify it.
Returns a (kind, char) tuple where kind is one of: "up", "down", "enter", "esc",
"space", "backspace", "char", "interrupt", "other". This is used instead of
readchar because readchar blocks after a lone ESC (waiting to see whether it is an
arrow sequence); here a short select() timeout distinguishes a real Escape press.
UTF-8 multibyte input (e.g. Cyrillic in a password) is decoded fully.
Читает одно нажатие в сыром режиме и классифицирует его. Возвращает кортеж
(kind, char). Используется вместо readchar, потому что readchar зависает после
одиночного ESC (ожидая, не последовательность ли это стрелок); здесь короткий
таймаут select() отличает настоящий Escape. UTF-8 (например кириллица в пароле)
декодируется полностью.
"""
if not _HAS_TERMIOS: # pragma: no cover
ch = sys.stdin.read(1)
return ("char", ch)
fd = sys.stdin.fileno()
old = termios.tcgetattr(fd)
try:
tty.setraw(fd)
b = os.read(fd, 1)
if not b:
return ("other", "")
c = b[0]
if c == 0x1B: # ESC — could be a lone Escape or an arrow/escape sequence
ready, _, _ = _select.select([fd], [], [], _ESC_TIMEOUT)
if not ready:
return ("esc", "")
seq = os.read(fd, 3)
last = seq[-1:] if seq else b""
if last == b"A":
return ("up", "")
if last == b"B":
return ("down", "")
if last in (b"C", b"D"):
return ("other", "")
return ("esc", "")
if c in (0x0D, 0x0A): # Enter
return ("enter", "")
if c == 0x03: # Ctrl-C (raw mode swallows SIGINT)
return ("interrupt", "")
if c == 0x20: # Space
return ("space", " ")
if c in (0x7F, 0x08): # Backspace / Delete
return ("backspace", "")
if c < 0x20: # other control char — ignore
return ("other", "")
# Printable byte — read any UTF-8 continuation bytes so multibyte chars decode.
# Печатный байт — дочитываем продолжения UTF-8, чтобы многобайтовые символы декодировались.
extra = 0
if c >= 0xF0:
extra = 3
elif c >= 0xE0:
extra = 2
elif c >= 0xC0:
extra = 1
if extra:
b += os.read(fd, extra)
return ("char", b.decode("utf-8", errors="ignore"))
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old)
# Block headers and footers
# Заголовки и завершения блоков
def header(title: str, subtitle: str = "") -> None:
"""Print a styled header block that marks the start of a task or wizard step.
Печатает стилизованный блок-заголовок, обозначающий начало задачи или шага мастера.
"""
console.print()
bar = Text("", style="bold cyan")
bar.append(title, style="bold")
if subtitle:
bar.append(f" {subtitle}", style="dim")
console.print(bar)
def done(success: bool, message: str = "") -> None:
"""Print the final status line of a task (green ✓ on success, red ✗ on failure).
Печатает финальную строку статуса задачи (зелёная ✓ при успехе, красная ✗ при ошибке).
"""
if success:
line = Text(f"{_OK} ", style="bold green")
line.append(message or "Done", style="green")
else:
line = Text(f"{_FAIL} ", style="bold red")
line.append(message or "Failed", style="red")
console.print(line)
def note(message: str) -> None:
"""Print a dim helper/info line.
Печатает приглушённую вспомогательную/информационную строку.
"""
console.print(Text(f" {message}", style="dim"))
def info(message: str) -> None:
"""Print a plain message through the shared console (Rich markup allowed).
Печатает обычное сообщение через общий console (разрешена разметка Rich).
"""
console.print(message)
def error(message: str) -> None:
"""Print an error line.
Печатает строку ошибки.
"""
console.print(f"[bold red]Error:[/bold red] {message}")
# A collapsed answer line, printed after an interactive block is resolved.
# Свёрнутая строка-ответ, печатается после разрешения интерактивного блока.
def _print_answer(question: str, answer: str) -> None:
line = Text(f"{_OK} ", style="bold green")
line.append(f"{question} ", style="dim")
line.append("· ", style="dim")
line.append(answer, style="bold")
console.print(line)
def _print_cancelled(question: str) -> None:
line = Text(f"{_FAIL} ", style="bold red")
line.append(f"{question} ", style="dim")
line.append("· cancelled", style="red")
console.print(line)
def _render_choices(question: str, options: Sequence[str], cursor: int,
note_text: str = "") -> Panel:
"""Build the renderable shown while the user is navigating a single-choice list.
Строит отрисовываемый объект, показываемый пока пользователь навигирует по списку выбора.
"""
rows: List[Text] = []
for i, opt in enumerate(options):
if i == cursor:
row = Text(f" {_CURSOR} ", style="bold cyan")
row.append(opt, style="bold")
else:
row = Text(f" {opt}", style="dim")
rows.append(row)
body = Group(*rows)
title = Text(question, style="bold")
sub = "↑/↓ — выбор · Enter — подтвердить · Esc — отмена"
if note_text:
sub = f"{note_text}\n{sub}"
return Panel(body, title=title, title_align="left", subtitle=Text(sub, style="dim"),
subtitle_align="left", border_style="cyan", padding=(0, 1))
def select(question: str, options: Sequence[str], default: Optional[str] = None,
note: str = "") -> Optional[str]:
"""Show an arrow-key single-choice block and return the chosen option string.
Returns None if the user pressed Escape / Ctrl-C. When the terminal is not
interactive the default (or first option) is returned without prompting.
Показывает блок выбора одного варианта со стрелками и возвращает выбранную строку.
Возвращает None, если пользователь нажал Escape / Ctrl-C. Если терминал не
интерактивный, возвращается значение по умолчанию (или первый вариант) без запроса.
"""
options = list(options)
if not options:
return None
cursor = options.index(default) if default in options else 0
if not is_interactive():
chosen = options[cursor]
_print_answer(question, chosen)
return chosen
with Live(_render_choices(question, options, cursor, note), console=console,
auto_refresh=False, transient=True) as live:
while True:
live.update(_render_choices(question, options, cursor, note), refresh=True)
kind, ch = _read_key()
if kind == "up" or (kind == "char" and ch == "k"):
cursor = (cursor - 1) % len(options)
elif kind == "down" or (kind == "char" and ch == "j"):
cursor = (cursor + 1) % len(options)
elif kind == "enter":
break
elif kind in ("esc", "interrupt"):
_print_cancelled(question)
return None
chosen = options[cursor]
_print_answer(question, chosen)
return chosen
def _render_multi(question: str, options: Sequence[str], cursor: int,
selected: set, note_text: str = "") -> Panel:
"""Build the renderable for a multi-choice checkbox list.
Строит отрисовываемый объект для списка множественного выбора с чекбоксами.
"""
rows: List[Text] = []
for i, opt in enumerate(options):
box = _CHECK_ON if i in selected else _CHECK_OFF
if i == cursor:
row = Text(f" {_CURSOR} {box} ", style="bold cyan")
row.append(opt, style="bold")
else:
row = Text(f" {box} ", style="green" if i in selected else "dim")
row.append(opt, style="" if i in selected else "dim")
rows.append(row)
body = Group(*rows)
title = Text(question, style="bold")
sub = "↑/↓ — навигация · Space — отметить · Enter — подтвердить · Esc — отмена"
if note_text:
sub = f"{note_text}\n{sub}"
return Panel(body, title=title, title_align="left", subtitle=Text(sub, style="dim"),
subtitle_align="left", border_style="cyan", padding=(0, 1))
def multiselect(question: str, options: Sequence[str],
defaults: Optional[Sequence[str]] = None,
note: str = "") -> Optional[List[str]]:
"""Show an arrow-key multi-choice block. Space toggles, Enter confirms.
Returns the list of selected option strings, or None if cancelled.
Показывает блок множественного выбора со стрелками. Space переключает, Enter подтверждает.
Возвращает список выбранных строк или None при отмене.
"""
options = list(options)
if not options:
return []
if defaults is None:
selected = set(range(len(options)))
else:
selected = {i for i, o in enumerate(options) if o in defaults}
cursor = 0
if not is_interactive():
chosen = [options[i] for i in sorted(selected)]
_print_answer(question, ", ".join(chosen) or "")
return chosen
with Live(_render_multi(question, options, cursor, selected, note), console=console,
auto_refresh=False, transient=True) as live:
while True:
live.update(_render_multi(question, options, cursor, selected, note), refresh=True)
kind, ch = _read_key()
if kind == "up" or (kind == "char" and ch == "k"):
cursor = (cursor - 1) % len(options)
elif kind == "down" or (kind == "char" and ch == "j"):
cursor = (cursor + 1) % len(options)
elif kind == "space":
selected.symmetric_difference_update({cursor})
elif kind == "enter":
break
elif kind in ("esc", "interrupt"):
_print_cancelled(question)
return None
chosen = [options[i] for i in sorted(selected)]
_print_answer(question, ", ".join(chosen) or "")
return chosen
def text(question: str, default: str = "", note: str = "") -> Optional[str]:
"""Prompt for a single line of free text, pre-filled with default.
Returns the entered value (or default if left empty), or None on Ctrl-C / EOF.
Запрашивает одну строку произвольного текста, предзаполненную значением по умолчанию.
Возвращает введённое значение (или default, если пусто), либо None при Ctrl-C / EOF.
"""
prompt = Text()
prompt.append(f"{_CURSOR} ", style="bold cyan")
prompt.append(question, style="bold")
if default:
prompt.append(f" [{default}]", style="dim")
console.print(prompt)
if note:
console.print(Text(f" {note}", style="dim"))
try:
raw = input(" > ").strip()
except (EOFError, KeyboardInterrupt):
console.print()
_print_cancelled(question)
return None
value = raw or default
return value
def confirm(question: str, default: bool = True) -> bool:
"""Yes/No selection block. Returns True for yes, False for no or cancel.
Блок выбора Да/Нет. Возвращает True для да, False для нет или отмены.
"""
yes, no = "Да", "Нет"
choice = select(question, [yes, no], default=yes if default else no)
return choice == yes
+2 -2
View File
@@ -2,12 +2,12 @@ from setuptools import setup, find_packages
setup(
name="lightweight-cobot",
version="2026.05.31",
version="2026.06.11",
description="CLI tool for installing, configuring and managing the ROS 2 cobot workspace",
packages=find_packages(),
python_requires=">=3.11",
install_requires=[
"textual",
"rich",
"ruamel.yaml",
],
entry_points={