From ee047618cd3d061fbebcd384e90d37ecde921fd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=B0=D0=BD=D0=B8=D0=B8=D0=BB=20=D0=93=D1=80=D0=B0?= =?UTF-8?q?=D0=B1=D0=B0=D1=80=D1=8C?= Date: Thu, 11 Jun 2026 12:21:02 +1000 Subject: [PATCH] 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 --- cobot/cli.py | 13 +- cobot/commands/clean.py | 105 ++---- cobot/commands/delete.py | 314 ++++++---------- cobot/commands/doc_setup.py | 372 +++++++------------ cobot/commands/docker_setup.py | 436 +++++++---------------- cobot/commands/local_setup.py | 629 +++++++++++++-------------------- cobot/commands/rebuild.py | 201 ++++------- cobot/commands/robot_setup.py | 401 +++++++++------------ cobot/commands/run.py | 404 +++++---------------- cobot/commands/setup.py | 73 +--- cobot/commands/update.py | 174 +++------ cobot/privilege.py | 176 +++++++++ cobot/process.py | 332 +++++++++++++++++ cobot/tui.py | 463 ------------------------ cobot/ui.py | 358 +++++++++++++++++++ setup.py | 4 +- 16 files changed, 1877 insertions(+), 2578 deletions(-) create mode 100644 cobot/privilege.py create mode 100644 cobot/process.py delete mode 100644 cobot/tui.py create mode 100644 cobot/ui.py diff --git a/cobot/cli.py b/cobot/cli.py index fc3b7c3..4da47ef 100644 --- a/cobot/cli.py +++ b/cobot/cli.py @@ -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): diff --git a/cobot/commands/clean.py b/cobot/commands/clean.py index c9f09cc..d798d96 100644 --- a/cobot/commands/clean.py +++ b/cobot/commands/clean.py @@ -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) diff --git a/cobot/commands/delete.py b/cobot/commands/delete.py index 30dd6a6..d2a9c89 100644 --- a/cobot/commands/delete.py +++ b/cobot/commands/delete.py @@ -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. - # Удаляем все пакеты 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) diff --git a/cobot/commands/doc_setup.py b/cobot/commands/doc_setup.py index 62e9727..fcfd4f5 100644 --- a/cobot/commands/doc_setup.py +++ b/cobot/commands/doc_setup.py @@ -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) diff --git a/cobot/commands/docker_setup.py b/cobot/commands/docker_setup.py index 15ef4b6..615ee69 100644 --- a/cobot/commands/docker_setup.py +++ b/cobot/commands/docker_setup.py @@ -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}:-{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}:-{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) diff --git a/cobot/commands/local_setup.py b/cobot/commands/local_setup.py index 16ea8cc..82aa5e4 100644 --- a/cobot/commands/local_setup.py +++ b/cobot/commands/local_setup.py @@ -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::