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 argparse
import sys
# Import each command module so we can register its subparser. # Import each command module so we can register its subparser.
# Импортируем каждый модуль команды, чтобы зарегистрировать его подпарсер. # Импортируем каждый модуль команды, чтобы зарегистрировать его подпарсер.
@@ -96,7 +95,17 @@ def main():
_register_commands(subparsers) _register_commands(subparsers)
args = parser.parse_args() 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): def _register_commands(subparsers):
+28 -77
View File
@@ -3,11 +3,10 @@ from __future__ import annotations
import argparse import argparse
import shutil import shutil
from pathlib import Path from pathlib import Path
from typing import List, Optional from typing import List
from textual.app import App from cobot import ui
from cobot.ui import header, done
from cobot.tui import SCREEN_CSS, LogScreen, MultiPickScreen
_PROJECT_DIR = Path(__file__).parent.parent.parent _PROJECT_DIR = Path(__file__).parent.parent.parent
@@ -19,80 +18,21 @@ _DIR_MAP = {
} }
def _task_clean(screen: LogScreen, dirs: List[str]) -> None: def _clean(dirs: List[str]) -> None:
"""Delete the selected top-level directories. """Delete the selected top-level directories, printing the outcome of each.
Удаляет выбранные директории верхнего уровня. Удаляет выбранные директории верхнего уровня, печатая результат по каждой.
""" """
try: header("Очистка артефактов сборки")
screen.write("[bold]Cleaning build artifacts[/bold]\n") removed = False
total = len(dirs) for label in dirs:
for i, label in enumerate(dirs): path = _DIR_MAP[label]
if screen.is_stopped(): if path.exists():
return shutil.rmtree(path)
screen.set_progress(i / total * 100, f"Removing {label}...") ui.info(f" [green]✓[/green] Удалено {label}")
path = _DIR_MAP[label] removed = True
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)
else: else:
self._ask_dirs() ui.info(f" [dim]Нет:[/dim] {label}")
done(True, "Очищено" if removed else "Нечего удалять")
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(),
)
def register(subparsers: argparse._SubParsersAction) -> None: def register(subparsers: argparse._SubParsersAction) -> None:
@@ -114,4 +54,15 @@ def run(args: argparse.Namespace) -> None:
"""Entry point for the clean command. """Entry point for the clean command.
Точка входа для команды clean. Точка входа для команды 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 shutil
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Callable
from textual.app import App from cobot import ui
from cobot import privilege
from cobot.tui import SCREEN_CSS, LogScreen, PickScreen from cobot.ui import done
from cobot.process import StepProgress
_PROJECT_DIR = Path(__file__).parent.parent.parent _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(log: Log) -> None:
def _stop_docker_containers(write) -> None:
"""Stop and force-remove all Docker containers whose name contains "lwc". """Stop and force-remove all Docker containers whose name contains "lwc".
Останавливает и принудительно удаляет все Docker-контейнеры с "lwc" в имени. Останавливает и принудительно удаляет все Docker-контейнеры с "lwc" в имени.
""" """
write("[cyan][*][/cyan] Stopping Docker containers...") log("[cyan][/cyan] Остановка Docker-контейнеров...")
result = subprocess.run( result = subprocess.run(
["docker", "ps", "-a", "--filter", "name=lwc", "--format", "{{.Names}}"], ["docker", "ps", "-a", "--filter", "name=lwc", "--format", "{{.Names}}"],
capture_output=True, text=True, capture_output=True, text=True,
) )
containers = [c for c in result.stdout.strip().splitlines() if c] containers = [c for c in result.stdout.strip().splitlines() if c]
if not containers: if not containers:
write("[dim]No project containers found.[/dim]") log("[dim]Контейнеры проекта не найдены.[/dim]")
return return
for name in containers: for name in containers:
subprocess.run(["docker", "rm", "-f", name], capture_output=True) 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". def _remove_docker_images(log: Log) -> None:
# Удаляем все Docker-образы, репозиторий или тег которых содержит "lwc".
def _remove_docker_images(write) -> None:
"""Force-remove all local Docker images whose name or tag contains "lwc". """Force-remove all local Docker images whose name or tag contains "lwc".
Принудительно удаляет все локальные Docker-образы с "lwc" в имени или теге. Принудительно удаляет все локальные Docker-образы с "lwc" в имени или теге.
""" """
write("[cyan][*][/cyan] Removing Docker images...") log("[cyan][/cyan] Удаление Docker-образов...")
result = subprocess.run( result = subprocess.run(
["docker", "images", "--format", "{{.Repository}}:{{.Tag}}"], ["docker", "images", "--format", "{{.Repository}}:{{.Tag}}"],
capture_output=True, text=True, capture_output=True, text=True,
) )
project_images = [ project_images = [img for img in result.stdout.strip().splitlines() if "lwc" in img.lower()]
img for img in result.stdout.strip().splitlines()
if "lwc" in img.lower()
]
if not project_images: if not project_images:
write("[dim]No project images found.[/dim]") log("[dim]Образы проекта не найдены.[/dim]")
return return
for img in project_images: for img in project_images:
subprocess.run(["docker", "rmi", "-f", img], capture_output=True) 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. def _remove_webots_volume(log: Log) -> None:
# Удаляем Docker volume с кэшем ассетов Webots. """Remove the lwc-webots-cache Docker volume if it exists.
def _remove_webots_volume(write) -> None: Удаляет Docker volume lwc-webots-cache если он существует.
"""Remove the lwc-webots-cache Docker volume if it exists. Skips silently if absent.
Удаляет Docker volume lwc-webots-cache если он существует. Молча пропускает если отсутствует.
""" """
result = subprocess.run( result = subprocess.run(["docker", "volume", "inspect", "lwc-webots-cache"], capture_output=True)
["docker", "volume", "inspect", "lwc-webots-cache"],
capture_output=True,
)
if result.returncode != 0: if result.returncode != 0:
write("[dim]Webots cache volume not found, skipping.[/dim]") log("[dim]Volume кэша Webots не найден, пропускаем.[/dim]")
return return
subprocess.run(["docker", "volume", "rm", "lwc-webots-cache"], capture_output=True) 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. def _remove_ros2(log: Log) -> None:
# Uses the official removal commands to also unregister the ROS2 apt repository. """Remove all ros-jazzy-* packages, the ros2-apt-source, and the ROS2 source line.
# Удаляем пакеты ROS2 Jazzy через apt и очищаем строку source из конфигов оболочки. Удаляет все пакеты ros-jazzy-*, ros2-apt-source и строку source ROS2 из конфигов.
# Используем официальные команды удаления, которые также снимают регистрацию 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 отсутствует.
""" """
write("[cyan][*][/cyan] Removing ROS2 Jazzy packages...") log("[cyan][/cyan] Удаление пакетов ROS2 Jazzy...")
if not Path("/opt/ros/jazzy").exists(): if not Path("/opt/ros/jazzy").exists():
write("[dim]ROS2 Jazzy not found, skipping.[/dim]") log("[dim]ROS2 Jazzy не найден, пропускаем.[/dim]")
else: else:
# Remove all ros-jazzy-* packages matched by the apt regex pattern ~n<name>. subprocess.run(privilege.sudo(["apt", "remove", "-y", "~nros-jazzy-*"]), capture_output=True)
# Удаляем все пакеты ros-jazzy-* по regex-паттерну apt ~n<имя>. subprocess.run(privilege.sudo(["apt", "autoremove", "-y"]), capture_output=True)
subprocess.run( log("[green]✓[/green] Пакеты ROS2 Jazzy удалены")
["sudo", "apt", "remove", "-y", "~nros-jazzy-*"], subprocess.run(privilege.sudo(["apt", "remove", "-y", "ros2-apt-source"]), capture_output=True)
capture_output=True, subprocess.run(privilege.sudo(["apt", "update", "-qq"]), capture_output=True)
) subprocess.run(privilege.sudo(["apt", "autoremove", "-y"]), capture_output=True)
subprocess.run(["sudo", "apt", "autoremove", "-y"], capture_output=True) log("[green]✓[/green] apt-репозиторий ROS2 удалён")
write("[green][ok][/green] ROS2 Jazzy packages removed")
# 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" source_line = "source /opt/ros/jazzy/setup.bash"
for rc_name in [".bashrc", ".zshrc"]: for rc_name in [".bashrc", ".zshrc"]:
rc = Path.home() / rc_name rc = Path.home() / rc_name
@@ -116,32 +88,24 @@ def _remove_ros2(write) -> None:
content = rc.read_text() content = rc.read_text()
if source_line not in content: if source_line not in content:
continue 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 = content.replace(f"\n# ROS2 Jazzy\n{source_line}\n", "\n")
new_content = new_content.replace(source_line, "") new_content = new_content.replace(source_line, "")
rc.write_text(new_content) 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. def _remove_webots(log: Log) -> None:
# Удаляем Webots из системы через apt. """Remove the webots package and clean WEBOTS_HOME from shell configs.
def _remove_webots(write) -> None: Удаляет пакет webots и очищает WEBOTS_HOME из конфигов оболочки.
"""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.
""" """
write("[cyan][*][/cyan] Removing Webots...") log("[cyan][/cyan] Удаление Webots...")
if not shutil.which("webots"): if not shutil.which("webots"):
write("[dim]Webots not found, skipping.[/dim]") log("[dim]Webots не найден, пропускаем.[/dim]")
return return
subprocess.run(["sudo", "apt", "remove", "-y", "webots"], capture_output=True) subprocess.run(privilege.sudo(["apt", "remove", "-y", "webots"]), capture_output=True)
subprocess.run(["sudo", "apt", "autoremove", "-y"], capture_output=True) subprocess.run(privilege.sudo(["apt", "autoremove", "-y"]), capture_output=True)
write("[green][ok][/green] Webots removed") 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"]: for rc_name in [".bashrc", ".zshrc"]:
rc = Path.home() / rc_name rc = Path.home() / rc_name
if not rc.exists(): if not rc.exists():
@@ -154,154 +118,65 @@ def _remove_webots(write) -> None:
new_content = new_content.replace("# Webots\n", "") new_content = new_content.replace("# Webots\n", "")
if new_content != content: if new_content != content:
rc.write_text(new_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. def _uninstall_cobot(log: Log) -> None:
# Удаляем cobot CLI из хранилища инструментов uv.
def _uninstall_cobot(write) -> None:
"""Uninstall the lightweight-cobot package from the uv tool store. """Uninstall the lightweight-cobot package from the uv tool store.
Удаляет пакет lightweight-cobot из хранилища инструментов uv. Удаляет пакет lightweight-cobot из хранилища инструментов uv.
""" """
write("[cyan][*][/cyan] Uninstalling cobot CLI...") log("[cyan][/cyan] Удаление cobot CLI...")
result = subprocess.run( result = subprocess.run(["uv", "tool", "uninstall", "lightweight-cobot"],
["uv", "tool", "uninstall", "lightweight-cobot"], capture_output=True, text=True)
capture_output=True, text=True,
)
if result.returncode == 0: if result.returncode == 0:
write("[green][ok][/green] cobot uninstalled") log("[green][/green] cobot удалён")
else: 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(log: Log) -> None:
# Удаляем всю директорию проекта с диска. """Recursively 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) с диска.
""" """
write(f"[cyan][*][/cyan] Removing project directory...") log("[cyan][/cyan] Удаление директории проекта...")
try: shutil.rmtree(_PROJECT_DIR)
shutil.rmtree(_PROJECT_DIR) log(f"[green]✓[/green] Удалено {_PROJECT_DIR}")
write(f"[green][ok][/green] Removed {_PROJECT_DIR}")
except Exception as exc:
write(f"[red]Failed:[/red] {exc}")
raise
# Run all deletion steps in order. def _delete(remove_ros: bool, remove_webots: bool) -> None:
# Progress ranges are split evenly across the active steps so the bar always reaches 100%. """Run all deletion steps in order, with progress split across the active steps.
# Выполняем все шаги удаления по порядку. Выполняет все шаги удаления по порядку, распределяя прогресс между активными шагами.
# Диапазоны прогресса делятся равномерно между активными шагами, чтобы бар всегда доходил до 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 -> директория.
""" """
try: ok, fail_msg = True, ""
screen.set_progress(0, "Stopping containers...") with StepProgress("Удаление проекта") as p:
_stop_docker_containers(screen.write) try:
_remove_webots_volume(screen.write) p.set(0, "Остановка контейнеров...")
_stop_docker_containers(p.raw)
_remove_webots_volume(p.raw)
screen.set_progress(20, "Removing Docker images...") p.set(20, "Удаление Docker-образов...")
_remove_docker_images(screen.write) _remove_docker_images(p.raw)
pct = 40 pct = 40
if remove_ros: if remove_ros:
screen.set_progress(pct, "Removing ROS2 Jazzy...") p.set(pct, "Удаление ROS2 Jazzy...")
_remove_ros2(screen.write) _remove_ros2(p.raw)
pct = 65 pct = 65
if remove_webots:
p.set(pct, "Удаление Webots...")
_remove_webots(p.raw)
pct = 75
if remove_webots: p.set(pct, "Удаление cobot CLI...")
screen.set_progress(pct, "Removing Webots...") _uninstall_cobot(p.raw)
_remove_webots(screen.write)
pct = 75
screen.set_progress(pct, "Uninstalling cobot CLI...") p.set(88, "Удаление директории проекта...")
_uninstall_cobot(screen.write) _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...") done(ok, "Проект полностью удалён" if ok else fail_msg)
_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(),
)
def register(subparsers: argparse._SubParsersAction) -> None: def register(subparsers: argparse._SubParsersAction) -> None:
@@ -313,4 +188,23 @@ def register(subparsers: argparse._SubParsersAction) -> None:
def run(args: argparse.Namespace) -> 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 subprocess
import sys import sys
from pathlib import Path from pathlib import Path
from typing import Callable, Optional
from textual.app import App from cobot import process, ui
from cobot.ui import done
from cobot.tui import SCREEN_CSS, InputScreen, LogScreen from cobot.process import StepProgress
_PROJECT_DIR = Path(__file__).parent.parent.parent _PROJECT_DIR = Path(__file__).parent.parent.parent
# The documentation source lives inside the project. We mount it into the container so # The documentation source lives inside the project; it is mounted into the container
# MkDocs can pick up live edits without rebuilding the image. # so MkDocs picks up live edits without rebuilding the image.
# Исходники документации находятся внутри проекта. Монтируем директорию в контейнер, чтобы # Исходники документации находятся внутри проекта; директория монтируется в контейнер,
# MkDocs мог подхватывать изменения вживую без пересборки образа. # чтобы MkDocs подхватывал изменения вживую без пересборки образа.
_DOC_DIR = _PROJECT_DIR / "doc" / "lwc-doc" _DOC_DIR = _PROJECT_DIR / "doc" / "lwc-doc"
_IMAGE_NAME = "lwc-docs" _IMAGE_NAME = "lwc-docs"
_CONTAINER_NAME = "lwc-docs" _CONTAINER_NAME = "lwc-docs"
_DEFAULT_PORT = "8000" _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: def _docker(*args: str, capture: bool = False) -> subprocess.CompletedProcess:
"""Run a docker subcommand. Pass capture=True to capture stdout/stderr instead of printing. """Run a docker subcommand.
Запускает подкоманду docker. capture=True перехватывает stdout/stderr вместо вывода на экран. Запускает подкоманду docker.
""" """
return subprocess.run(["docker", *args], capture_output=capture, text=True) return subprocess.run(["docker", *args], capture_output=capture, text=True)
# Check whether the docs container is currently running.
# Проверяем, запущен ли сейчас контейнер с документацией.
def _is_running() -> bool: def _is_running() -> bool:
"""Return True if the lwc-docs container is currently running. """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) r = _docker("ps", "--filter", f"name={_CONTAINER_NAME}", "--format", "{{.Names}}", capture=True)
return _CONTAINER_NAME in r.stdout return _CONTAINER_NAME in r.stdout
# Check whether the docs Docker image has already been built.
# Проверяем, был ли уже собран Docker-образ для документации.
def _image_exists() -> bool: def _image_exists() -> bool:
"""Return True if the lwc-docs Docker image exists locally. """Return True if the lwc-docs Docker image exists locally.
Возвращает True если Docker-образ lwc-docs существует локально. Возвращает True если Docker-образ lwc-docs существует локально.
@@ -55,259 +46,132 @@ def _image_exists() -> bool:
return bool(_docker("images", "-q", _IMAGE_NAME, capture=True).stdout.strip()) return bool(_docker("images", "-q", _IMAGE_NAME, capture=True).stdout.strip())
# Build the MkDocs Docker image. Only needs to run once. def _build_docs_image(p: StepProgress, lo: float, hi: float) -> bool:
# Progress comes from parsing "Step X/Y" lines in the docker build output. """Build the lwc-docs image, mapping "Step X/Y" to the lo..hi progress slice.
# Собираем Docker-образ MkDocs. Нужно сделать только один раз. Собирает образ lwc-docs, отображая "Step X/Y" на участок lo..hi прогресса.
# Прогресс получаем, парся строки "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 при успехе.
""" """
write("[cyan][*][/cyan] Building documentation image (runs once)...") p.raw("[cyan][/cyan] Сборка образа документации (один раз)...")
# DOCKER_BUILDKIT=0 gives us "Step X/Y" lines that we can parse for progress.
# DOCKER_BUILDKIT=0 даёт нам строки "Step X/Y", которые можно парсить для прогресса.
env = {**os.environ, "DOCKER_BUILDKIT": "0"} env = {**os.environ, "DOCKER_BUILDKIT": "0"}
proc = subprocess.Popen(
["docker", "build", "-t", _IMAGE_NAME, str(_DOC_DIR)], def on_line(s: str) -> None:
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=env,
)
if register_proc:
register_proc(proc)
for line in proc.stdout:
s = line.rstrip()
if s: if s:
write(s) p.log(s)
if on_progress: m = re.match(r"Step (\d+)/(\d+) :", s)
m = re.match(r"Step (\d+)/(\d+) :", line) if m:
if m: step, total = int(m.group(1)), int(m.group(2))
step, total = int(m.group(1)), int(m.group(2)) p.set(lo + step / total * (hi - lo), f"шаг {step}/{total}")
on_progress(step / total * 100)
proc.wait() rc = process.stream(["docker", "build", "-t", _IMAGE_NAME, str(_DOC_DIR)],
if proc.returncode in (-9, -15): env=env, on_line=on_line)
if rc != 0:
p.raw("[red]Сборка образа не удалась.[/red]")
return False return False
if proc.returncode == 0: p.raw("[green]✓[/green] Образ документации готов")
write("[green][ok][/green] Documentation image ready") return True
return True
write("[red]Image build failed.[/red]")
return False
# Start the docs server. Builds the image first if it does not exist yet. def _start_container(p: StepProgress, port: str) -> bool:
# Запускаем сервер документации. Сначала собирает образ, если он ещё не существует. """Start the MkDocs container on the given port. Returns True on success.
def _task_up(screen: LogScreen, port: str) -> None: Запускает контейнер MkDocs на заданном порту. Возвращает True при успехе.
"""Worker function for the "up" action. Builds the image if missing, then starts the container.
Рабочая функция для действия "up". Собирает образ если отсутствует, затем запускает контейнер.
""" """
try: p.set(90, "Запуск сервера MkDocs...")
if _is_running(): p.raw("[cyan]▸[/cyan] Запуск сервера MkDocs...")
screen.write(f"[green]Docs already running at:[/green] http://localhost:{port}") result = _docker(
screen.write(" Stop with: [bold]cobot doc-setup down[/bold]") "run", "-d", "--name", _CONTAINER_NAME, "--rm",
if not screen.is_stopped(): "-p", f"{port}:8000",
screen.finish(True) "-v", f"{_DOC_DIR}:/docs",
return _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(): if not _image_exists():
screen.set_progress(0, "Building documentation image...") p.set(0, "Сборка образа документации...")
ok = _build_docs_image( if not _build_docs_image(p, 0, 85):
screen.write, ok, fail_msg = False, "Сборка образа не удалась"
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
else: 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(): if ok:
return done(True, f"Документация доступна: http://localhost:{port}")
ui.note("Правьте файлы в doc/lwc-doc/docs/ — перезагрузка автоматическая.")
screen.set_progress(88, "Starting MkDocs server...") ui.note("Остановить: cobot doc-setup down")
screen.write("\n[cyan][*][/cyan] Starting MkDocs server...") else:
result = _docker( done(False, fail_msg)
"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)
# Stop the running docs container. def _task_down() -> None:
# Останавливаем работающий контейнер с документацией. """Stop the lwc-docs container if it is running.
def _task_down(screen: LogScreen) -> None: Останавливает контейнер lwc-docs если он запущен.
"""Worker function for the "down" action. Stops the lwc-docs container if it is running.
Рабочая функция для действия "down". Останавливает контейнер lwc-docs если он запущен.
""" """
try: if not _is_running():
if not _is_running(): ui.info("[yellow]Контейнер документации не запущен.[/yellow]")
screen.write("[yellow]Docs container is not running.[/yellow]") return
if not screen.is_stopped(): with StepProgress("Сервер документации") as p:
screen.finish(True) p.set(30, "Остановка контейнера...")
return p.raw("[cyan]▸[/cyan] Остановка сервера документации...")
screen.set_progress(30, "Stopping container...")
screen.write("[cyan][*][/cyan] Stopping documentation server...")
_docker("stop", _CONTAINER_NAME) _docker("stop", _CONTAINER_NAME)
if screen.is_stopped(): p.set(100, "Готово")
return done(True, "Контейнер остановлен")
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)
# Stop the container, remove the old image, rebuild it, and start a new container. def _task_rebuild(port: str) -> None:
# Останавливаем контейнер, удаляем старый образ, пересобираем и запускаем новый контейнер. """Stop the container, remove the old image, rebuild it, and start a fresh 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". Останавливает контейнер, удаляет старый образ,
пересобирает его и запускает новый контейнер на указанном порту.
""" """
try: ok, fail_msg = True, ""
with StepProgress("Сервер документации — пересборка") as p:
if _is_running(): if _is_running():
screen.set_progress(5, "Stopping container...") p.set(5, "Остановка контейнера...")
screen.write("[cyan][*][/cyan] Stopping existing container...")
_docker("stop", _CONTAINER_NAME) _docker("stop", _CONTAINER_NAME)
if screen.is_stopped(): p.raw("[green]✓[/green] Остановлен.")
return
screen.write("[green][ok][/green] Stopped.")
if _image_exists(): if _image_exists():
screen.set_progress(15, "Removing old image...") p.set(15, "Удаление старого образа...")
screen.write("[cyan][*][/cyan] Removing old image...")
_docker("rmi", "-f", _IMAGE_NAME) _docker("rmi", "-f", _IMAGE_NAME)
if screen.is_stopped(): p.raw("[green]✓[/green] Образ удалён.")
return p.set(20, "Сборка образа документации...")
screen.write("[green][ok][/green] Image removed.") 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...") if ok:
ok = _build_docs_image( done(True, f"Документация доступна: http://localhost:{port}")
screen.write, ui.note("Остановить: cobot doc-setup down")
on_progress=lambda p: screen.set_progress(20 + p * 0.68, "Building documentation image..."), else:
register_proc=screen.set_proc, done(False, fail_msg)
)
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)
# One app handles all three actions (up/down/rebuild) by branching in on_mount. def _normalize_port(value: str) -> str:
# Одно приложение обрабатывает все три действия (up/down/rebuild), разветвляясь в on_mount. """Return a numeric port string, falling back to the default when invalid.
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.
""" """
CSS = SCREEN_CSS value = (value or "").strip()
return value if value.isdigit() else _DEFAULT_PORT
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(),
)
def register(subparsers: argparse._SubParsersAction) -> None: def register(subparsers: argparse._SubParsersAction) -> None:
@@ -324,9 +188,21 @@ def register(subparsers: argparse._SubParsersAction) -> None:
def run(args: argparse.Namespace) -> None: def run(args: argparse.Namespace) -> None:
if not shutil.which("docker"): if not shutil.which("docker"):
from rich.console import Console ui.error("Docker не установлен или отсутствует в PATH.")
Console().print("[red]Error:[/red] Docker is not installed or not on PATH.")
sys.exit(1) sys.exit(1)
action = getattr(args, "action", "up") 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 os
import re import re
import shutil import shutil
import subprocess
import sys import sys
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Callable, List, Optional from typing import List, Optional
from textual.app import App from cobot import process, ui
from cobot.ui import done
from cobot.tui import SCREEN_CSS, InputScreen, LogScreen, PickScreen from cobot.process import StepProgress
_PROJECT_DIR = Path(__file__).parent.parent.parent _PROJECT_DIR = Path(__file__).parent.parent.parent
_DOCKER_DIR = _PROJECT_DIR / "docker" _DOCKER_DIR = _PROJECT_DIR / "docker"
@@ -27,8 +26,8 @@ _DEFAULT_PREFIX = "lwc-local"
_CONTROLLER_CHAIN = ["ros-core", "ros-base", "ros-iiwa7"] _CONTROLLER_CHAIN = ["ros-core", "ros-base", "ros-iiwa7"]
_WEBOTS_CHAIN = ["ros-core", "ros-base", "ros-iiwa7-webots"] _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). # Maps each image to the image it is built FROM. None means it starts from base Ubuntu.
# Сопоставляет каждый образ с тем, на основе которого он собирается. None - начинает с нуля (базовый Ubuntu). # Сопоставляет каждый образ с тем, на основе которого он собирается. None - базовый Ubuntu.
_IMAGE_PARENT: dict[str, str | None] = { _IMAGE_PARENT: dict[str, str | None] = {
"ros-core": None, "ros-core": None,
"ros-base": "ros-core", "ros-base": "ros-core",
@@ -36,15 +35,11 @@ _IMAGE_PARENT: dict[str, str | None] = {
"ros-iiwa7-webots": "ros-base", "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"} _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 @dataclass
class _Config: class _Config:
ros_version: str ros_version: str
@@ -55,29 +50,14 @@ class _Config:
hub_repo: str hub_repo: str
# Build one Docker image and stream its output to the log. def _build_image(name: str, tag: str, dockerfile: Path, ctx: Path, p: StepProgress,
# Progress is tracked by parsing "Step X/Y" lines that Docker prints during the build. lo: float, hi: float, parent_tag: Optional[str], build_type: str) -> bool:
# Собирает один Docker-образ и транслирует его вывод в лог. """Build a single Docker image, streaming output and mapping "Step X/Y" to the
# Прогресс отслеживается по строкам "Step X/Y", которые Docker печатает во время сборки. lo..hi slice of the progress bar. Returns True on success.
def _build_image( Собирает один Docker-образ, транслируя вывод и отображая "Step X/Y" на участок
name: str, lo..hi прогресс-бара. Возвращает True при успехе.
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 если сборка завершилась ошибкой или была отменена.
""" """
write(f"[cyan][*][/cyan] Building [bold]{name}[/bold]...") p.raw(f"[cyan][/cyan] Сборка [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" в выводе, которые мы парсим для прогресса.
env = {**os.environ, "DOCKER_BUILDKIT": "0"} env = {**os.environ, "DOCKER_BUILDKIT": "0"}
cmd = [ cmd = [
"docker", "build", "-t", tag, "-f", str(dockerfile), "docker", "build", "-t", tag, "-f", str(dockerfile),
@@ -87,315 +67,109 @@ def _build_image(
cmd += ["--build-arg", f"IMAGE={parent_tag}"] cmd += ["--build-arg", f"IMAGE={parent_tag}"]
cmd.append(str(ctx)) cmd.append(str(ctx))
proc = subprocess.Popen( def on_line(s: str) -> None:
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()
if s: if s:
write(s) p.log(s)
if on_progress: m = re.match(r"Step (\d+)/(\d+) :", s)
m = re.match(r"Step (\d+)/(\d+) :", line) if m:
if m: step, total = int(m.group(1)), int(m.group(2))
step, total = int(m.group(1)), int(m.group(2)) p.set(lo + step / total * (hi - lo), f"{name}: шаг {step}/{total}")
on_progress(step / total * 100)
proc.wait() rc = process.stream(cmd, env=env, on_line=on_line)
if proc.returncode in (-9, -15): if rc in (-9, -15):
return False return False
if proc.returncode == 0: if rc == 0:
write(f"[green][ok][/green] {name}") p.raw(f"[green][/green] {name}")
return True return True
write(f"[red]Build failed:[/red] {tag}") p.raw(f"[red]Сборка не удалась:[/red] {tag}")
return False return False
# Pull a Docker image from Hub and track progress by counting downloaded layers. def _pull_image(name: str, tag: str, p: StepProgress, lo: float, hi: float) -> bool:
# Скачиваем Docker-образ с Hub и отслеживаем прогресс по количеством скачанных слоёв. """Pull a Docker image, tracking progress by counting completed layers.
def _pull_image( Скачивает Docker-образ, отслеживая прогресс по числу завершённых слоёв.
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 если скачивание завершилось ошибкой или было отменено.
""" """
write(f"[cyan][*][/cyan] Pulling [bold]{name}[/bold] ({tag})...") p.raw(f"[cyan][/cyan] Скачивание [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)
layers_total = 0 layers_total = 0
layers_done = 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: if s:
write(s) p.log(s)
# Count layers as they appear and mark them done when Docker confirms they are pulled. if "Pulling fs layer" in s or "Waiting" in s:
# Считаем слои по мере их появления и отмечаем завершёнными когда Docker подтверждает скачивание.
if "Pulling fs layer" in line or "Waiting" in line:
layers_total += 1 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 layers_done += 1
if on_progress and layers_total > 0: if layers_total > 0:
on_progress(5 + layers_done / layers_total * 90) p.set(lo + layers_done / layers_total * (hi - lo), f"{name}: слои")
proc.wait()
if proc.returncode in (-9, -15): rc = process.stream(["docker", "pull", tag], on_line=on_line)
if rc in (-9, -15):
return False return False
if proc.returncode == 0: if rc == 0:
write(f"[green][ok][/green] {name}") p.raw(f"[green][/green] {name}")
if on_progress:
on_progress(100)
return True return True
write(f"[red]Pull failed:[/red] {tag}") p.raw(f"[red]Скачивание не удалось:[/red] {tag}")
return False return False
# The actual work - either build or pull all images depending on what the user chose. def _execute(cfg: _Config) -> None:
# Each image gets its own slice of the progress bar so the overall bar advances smoothly. """Build or pull all images in the chain selected by the user's choices.
# Основная работа - собираем или скачиваем все образы в зависимости от выбора пользователя. Собирает или скачивает все образы из цепочки, выбранной пользователем.
# Каждый образ получает свой кусок прогресс-бара, чтобы общий бар двигался равномерно.
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. Собирает или скачивает все образы из цепочки
согласно выбору пользователя и обновляет прогресс-бар после каждого образа.
""" """
try: chain = _WEBOTS_CHAIN if cfg.variant == "webots" else _CONTROLLER_CHAIN
chain = _WEBOTS_CHAIN if cfg.variant == "webots" else _CONTROLLER_CHAIN n = len(chain)
n = len(chain) ok = True
fail_msg = ""
if cfg.source == "build": if cfg.source == "build":
screen.write( title = f"Сборка {n} образ(ов) — ROS {cfg.ros_version}{cfg.build_type}"
f"[bold]Building {n} image(s) — " with StepProgress(title) as p:
f"ROS {cfg.ros_version}{cfg.build_type}[/bold]\n"
)
for i, name in enumerate(chain): for i, name in enumerate(chain):
if screen.is_stopped(): lo, hi = i / n * 100, (i + 1) / n * 100
return p.set(lo, f"Образ {i + 1}/{n}: {name}")
lo = i / n * 100
hi = (i + 1) / n * 100
screen.set_progress(lo, f"Image {i + 1}/{n}: building {name}...")
tag = f"{cfg.image_prefix}:{name}-{cfg.ros_version}" tag = f"{cfg.image_prefix}:{name}-{cfg.ros_version}"
dockerfile = _DOCKER_DIR / cfg.ros_version / name / "Dockerfile" dockerfile = _DOCKER_DIR / cfg.ros_version / name / "Dockerfile"
if not dockerfile.exists(): if not dockerfile.exists():
screen.write(f"[red]Dockerfile not found:[/red] {dockerfile}") ok, fail_msg = False, f"Dockerfile не найден: {dockerfile}"
if not screen.is_stopped(): break
screen.finish(False)
return
ctx = _PROJECT_DIR if name in _NEEDS_PROJECT_CTX else dockerfile.parent ctx = _PROJECT_DIR if name in _NEEDS_PROJECT_CTX else dockerfile.parent
parent_name = _IMAGE_PARENT.get(name) parent_name = _IMAGE_PARENT.get(name)
parent_tag = ( parent_tag = (f"{cfg.image_prefix}:{parent_name}-{cfg.ros_version}"
f"{cfg.image_prefix}:{parent_name}-{cfg.ros_version}" if parent_name else None)
if parent_name else None if not _build_image(name, tag, dockerfile, ctx, p, lo, hi,
) parent_tag, cfg.build_type):
ok = _build_image( ok, fail_msg = False, f"Сборка образа {name} не удалась"
name, tag, dockerfile, ctx, screen.write, break
on_progress=lambda p, lo=lo, hi=hi: screen.set_progress( if ok:
lo + p * (hi - lo) / 100, f"Image {i + 1}/{n}: building {name}..." p.set(100, "Готово")
), done(ok, f"Образы готовы: {cfg.image_prefix}:<name>-{cfg.ros_version}"
parent_tag=parent_tag, if ok else fail_msg)
build_type=cfg.build_type, else:
register_proc=screen.set_proc, short = "webots" if cfg.variant == "webots" else "iiwa"
) suffix = "-dev" if cfg.build_type == "dev" else ""
if screen.is_stopped(): full_ref = f"{cfg.hub_repo}:{short}-{cfg.ros_version}{suffix}"
return with StepProgress(f"Скачивание из {cfg.hub_repo} — ROS {cfg.ros_version}") as p:
if not ok: if _pull_image(short, full_ref, p, 0, 100):
screen.finish(False) p.set(100, "Готово")
return else:
screen.set_progress(hi) ok, fail_msg = False, f"Скачивание {full_ref} не удалось"
done(ok, f"Образ готов: {full_ref}" if ok else fail_msg)
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)
# 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]: def _discover_versions() -> List[str]:
"""Return a sorted list of ROS versions found in docker/. Jazzy is placed first. """Return ROS versions found in docker/ (jazzy first). Falls back to ["jazzy"].
Falls back to ["jazzy"] if the directory does not exist or is empty. Возвращает версии ROS из docker/ (jazzy первым). По умолчанию ["jazzy"].
Возвращает отсортированный список версий ROS найденных в docker/. Jazzy идёт первым.
Возвращает ["jazzy"] если директория не существует или пуста.
""" """
if not _DOCKER_DIR.exists(): if not _DOCKER_DIR.exists():
return ["jazzy"] return ["jazzy"]
dirs = sorted(d.name for d in _DOCKER_DIR.iterdir() if d.is_dir()) 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: if "jazzy" in dirs:
dirs = ["jazzy"] + [d for d in dirs if d != "jazzy"] dirs = ["jazzy"] + [d for d in dirs if d != "jazzy"]
return dirs or ["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: def register(subparsers: argparse._SubParsersAction) -> None:
p = subparsers.add_parser("docker-setup", help="Build or pull Docker images for KUKA iiwa7") p = subparsers.add_parser("docker-setup", help="Build or pull Docker images for KUKA iiwa7")
p.set_defaults(func=run) p.set_defaults(func=run)
@@ -403,10 +177,52 @@ def register(subparsers: argparse._SubParsersAction) -> None:
def run(args: argparse.Namespace) -> None: def run(args: argparse.Namespace) -> None:
if not shutil.which("docker"): if not shutil.which("docker"):
from rich.console import Console ui.error("Docker не установлен или отсутствует в PATH.")
Console().print("[red]Error:[/red] Docker is not installed or not on PATH.")
sys.exit(1) sys.exit(1)
ui.header("Настройка Docker", "сборка или скачивание образов KUKA iiwa7")
versions = _discover_versions() versions = _discover_versions()
default = "jazzy" if "jazzy" in versions else versions[0] 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 shutil
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from typing import Callable, List, Optional
from textual.app import App from cobot import process, ui
from cobot import privilege
from cobot.tui import SCREEN_CSS, LogScreen, PickScreen from cobot.ui import done, header
from cobot.commands.docker_setup import run as _docker_setup from cobot.commands.docker_setup import run as _docker_setup
# Root directory of the project, used as the working directory for colcon builds. # Root directory of the project, used as the working directory for colcon builds.
@@ -28,9 +27,12 @@ _WEBOTS_VERSION = "2025a"
# Директория с shell-скриптами, используемыми этой командой. # Директория с shell-скриптами, используемыми этой командой.
_SCRIPTS_DIR = _PROJECT_DIR / "scripts" _SCRIPTS_DIR = _PROJECT_DIR / "scripts"
# Type alias for the callable used to write a line to the TUI log screen. # apt packages that must exist before rosdep can install the pip-based keys
# Псевдоним типа для функции записи строки в лог TUI. # (python3-pip / dev / venv). Their absence is what produced the "pip is not
Write = Callable[[str], None] # 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 # OS and tool detection helpers
@@ -38,9 +40,7 @@ Write = Callable[[str], None]
def _detect_ubuntu_2404() -> bool: def _detect_ubuntu_2404() -> bool:
"""Return True if the current OS is Ubuntu 24.04 (Noble). """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). Возвращает True, если текущая ОС - Ubuntu 24.04 (Noble).
Читает /etc/os-release и проверяет поля ID и VERSION_ID.
""" """
path = Path("/etc/os-release") path = Path("/etc/os-release")
if not path.exists(): if not path.exists():
@@ -55,7 +55,6 @@ def _detect_ubuntu_2404() -> bool:
def _detect_ros2() -> bool: def _detect_ros2() -> bool:
"""Return True if ROS2 Jazzy is already installed under /opt/ros/jazzy. """Return True if ROS2 Jazzy is already installed under /opt/ros/jazzy.
Возвращает True, если ROS2 Jazzy уже установлен в /opt/ros/jazzy. Возвращает True, если ROS2 Jazzy уже установлен в /opt/ros/jazzy.
""" """
return Path(f"/opt/ros/{_DISTRO}").is_dir() return Path(f"/opt/ros/{_DISTRO}").is_dir()
@@ -63,7 +62,6 @@ def _detect_ros2() -> bool:
def webots_installed() -> bool: def webots_installed() -> bool:
"""Return True if the Webots binary is available on PATH. """Return True if the Webots binary is available on PATH.
Возвращает True, если бинарный файл Webots доступен в PATH. Возвращает True, если бинарный файл Webots доступен в PATH.
""" """
return shutil.which("webots") is not None 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. os.environ if the setup file does not exist yet.
Формирует словарь окружения с переменными ROS2, полученными из setup.bash. Формирует словарь окружения с переменными ROS2, полученными из setup.bash.
Запускает /opt/ros/jazzy/setup.bash в подпроцессе, перехватывает все
экспортированные переменные и объединяет их с копией os.environ.
Возвращает чистый os.environ если файл setup.bash ещё не существует. Возвращает чистый os.environ если файл setup.bash ещё не существует.
""" """
setup = Path(f"/opt/ros/{_DISTRO}/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, # 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. # 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), # CMake игнорирует PATH при поиске Python через find_package(Python3),
# поэтому явно указываем системный Python, где установлен catkin_pkg. # поэтому явно указываем системный Python, где установлен catkin_pkg.
# Одного изменения PATH недостаточно.
env["Python3_EXECUTABLE"] = "/usr/bin/python3" env["Python3_EXECUTABLE"] = "/usr/bin/python3"
env["PYTHON_EXECUTABLE"] = "/usr/bin/python3" env["PYTHON_EXECUTABLE"] = "/usr/bin/python3"
# Also keep PATH clean so other tools (rosdep, colcon itself) use system Python. # Keep PATH clean so other tools (rosdep, colcon itself) use system Python.
# Заодно чистим PATH чтобы другие инструменты тоже использовали системный Python. # Чистим PATH чтобы другие инструменты тоже использовали системный Python.
_SYSTEM_PATHS = ["/usr/bin", "/usr/local/bin"] _SYSTEM_PATHS = ["/usr/bin", "/usr/local/bin"]
existing = env.get("PATH", "").split(":") existing = env.get("PATH", "").split(":")
env["PATH"] = ":".join( env["PATH"] = ":".join(
@@ -113,393 +107,268 @@ def _ros2_env() -> dict:
return env return env
# Subprocess runner helpers # Bash-script runner that understands PROGRESS:<pct>:<label> markers
# Вспомогательные функции для запуска подпроцессов # Запуск bash-скриптов с поддержкой маркеров PROGRESS:<pct>:<метка>
def _run_logged( def _run_script(script: Path, title: str) -> int:
cmd: List[str], """Run a shell script, streaming its output to a live log and advancing the
write: Write, progress bar from PROGRESS:<pct>:<label> markers (which are not echoed raw).
env: dict | None = None, Returns the script exit code.
cwd=None, Запускает shell-скрипт, транслируя вывод в живой лог и продвигая прогресс-бар по
register_proc: Callable | None = None, маркерам PROGRESS:<pct>:<метка> (сами маркеры не печатаются). Возвращает код возврата.
) -> 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 считается нормальной отменой и не вызывает исключение).
""" """
proc = subprocess.Popen( if not script.exists():
cmd, header(title)
stdout=subprocess.PIPE, ui.error(f"Скрипт не найден: {script}")
stderr=subprocess.STDOUT, done(False, "Скрипт отсутствует")
text=True, return 1
env=env or os.environ,
cwd=cwd, with process.StepProgress(title) as p:
) def on_line(s: str) -> None:
if register_proc: if s.startswith("PROGRESS:"):
register_proc(proc) parts = s.split(":", 2)
for line in proc.stdout: try:
s = line.rstrip() p.set(float(parts[1]), parts[2] if len(parts) > 2 else "")
if s: except (ValueError, IndexError):
write(s) pass
proc.wait() return
if proc.returncode not in (0, -9): if s:
raise RuntimeError(f"Command failed: {cmd[0]}") 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 def install_ros2(pkg: str) -> bool:
# Задачи - долгие функции, выполняемые в фоновом потоке внутри LogScreen """Run the ROS2 Jazzy install shell script for the chosen variant (desktop / ros-base).
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.
Запускает shell-скрипт установки ROS2 Jazzy для выбранного варианта (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: try:
# "desktop" -> setup_ros2_desktop.sh, "ros-base" -> setup_ros2_ros_base.sh current = sources_list.read_text() if sources_list.exists() else ""
script = _SCRIPTS_DIR / f"setup_ros2_{pkg.replace('-', '_')}.sh" except Exception:
if not script.exists(): current = ""
screen.write(f"[red]Script not found:[/red] {script}") if current == entry:
screen.finish(False) return
return snippet = (
screen.set_progress(0, "Starting installation...") "mkdir -p /etc/ros/rosdep/sources.list.d && "
_run_script(script, screen) f"printf '%s\\n' 'yaml file://{rosdep_yaml}' > {sources_list}"
if not screen.is_stopped(): )
screen.set_progress(100, "Done") process.stream(privilege.sudo(["bash", "-c", snippet]), on_line=p.log)
screen.write(f"\n[green]ROS2 {_DISTRO} ({pkg}) installed successfully.[/green]") p.log(f"Зарегистрирован локальный источник rosdep: {rosdep_yaml}")
screen.finish(True) process.stream(["rosdep", "update"], env=env, cwd=str(_PROJECT_DIR), on_line=p.log)
except Exception as exc:
if not screen.is_stopped():
screen.write(f"\n[red]Error:[/red] {exc}")
screen.finish(False)
def _task_build(screen: LogScreen) -> None: def _count_colcon_packages(env: dict) -> int:
"""Build the project workspace using rosdep and colcon. """Count colcon packages under src/ so the build bar can show X / total.
Считает пакеты colcon в src/, чтобы бар сборки показывал X / всего.
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 в этой сессии терминала.
""" """
try: r = subprocess.run(
env = _ros2_env() ["colcon", "list", "--base-paths", "src"],
capture_output=True, text=True, cwd=str(_PROJECT_DIR), env=env,
if not shutil.which("colcon") and not Path(f"/opt/ros/{_DISTRO}/bin/colcon").exists(): )
screen.write("[red]colcon not found.[/red]") return max(len([l for l in r.stdout.splitlines() if l.strip()]), 1)
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)
def _task_install_webots(screen: LogScreen) -> None: def build_workspace() -> bool:
"""Run the Webots installation shell script, streaming output and progress to the TUI. """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: env = _ros2_env()
script = _SCRIPTS_DIR / "install_webots.sh" env["PIP_BREAK_SYSTEM_PACKAGES"] = "1"
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)
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 ok = True
# TUI приложение - управляет экранами и выборами пользователя fail_msg = ""
class _LocalSetupApp(App[Optional[str]]):
"""Main TUI application for the local-setup command.
Guides the user through: install ROS2 choice, OS check, version choice, with process.StepProgress("Сборка проекта") as p:
installation log, build log, and optional Webots installation. # --- Шаг 1/3: системные зависимости pip (apt) ---
Returns "docker" if the user opts for Docker setup, None otherwise. p.raw("[bold]Шаг 1/3 — системные зависимости pip (apt)[/bold]")
p.set(0, "Проверка python3-pip / dev / venv...")
Главное TUI приложение для команды local-setup. missing = _missing_apt_prereqs()
Проводит пользователя через: выбор установки ROS2, проверку ОС, выбор версии, if missing:
лог установки, лог сборки и опциональную установку Webots. p.log(f"Установка: {', '.join(missing)}")
Возвращает "docker" если пользователь выбирает Docker, иначе None. process.stream(privilege.sudo(["apt-get", "update", "-q"]), env=env, on_line=p.log)
""" rc = process.stream(
privilege.sudo(["apt-get", "install", "-y", *missing]),
CSS = SCREEN_CSS env=env, on_line=p.log,
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,
) )
if rc not in (0, -9, -15):
ok, fail_msg = False, "Не удалось установить apt-зависимости"
else: else:
self.push_screen( p.log("python3-pip / dev / venv уже установлены")
PickScreen( if ok:
"ROS2 version", _ensure_root_pip_break(p)
"Which ROS2 Jazzy variant do you want to install?",
["Desktop (full install, includes GUI tools)", "Base (minimal, no GUI)"], # --- Шаг 2/3: rosdep install ---
"Desktop (full install, includes GUI tools)", if ok:
), p.set(10, "rosdep install...")
self._on_version_choice, 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: # --- Шаг 3/3: colcon build ---
"""Exit the app signalling whether docker-setup should be launched. if ok:
Завершает приложение, сигнализируя нужно ли запустить docker-setup. total = _count_colcon_packages(env)
""" p.set(30, f"0 / {total} пакетов")
self.exit("docker" if choice and choice.startswith("Yes") else None) p.raw(f"\n[bold]Шаг 3/3 — colcon build ({total} пакетов)[/bold]")
built = 0
def _on_version_choice(self, choice: Optional[str]) -> None: def _on_build(s: str) -> None:
"""Start the installation log screen for the chosen ROS2 variant. nonlocal built
Запускает экран лога установки для выбранного варианта ROS2. if s:
""" p.log(s)
if not choice: if "Finished <<<" in s or "Failed <<<" in s:
self.exit(None) built += 1
return p.set(30 + built / total * 70, f"{built} / {total} пакетов")
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 _after_install(self) -> None: rc = process.stream(
"""After installation, ask whether to build the project workspace now. ["colcon", "build", "--base-paths", "src"],
После установки спрашивает, нужно ли собрать рабочее пространство прямо сейчас. env=env, cwd=str(_PROJECT_DIR), on_line=_on_build,
"""
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),
) )
else: if rc not in (0, -9, -15):
self.exit(None) 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]): # Interactive flow
"""Standalone TUI app for installing Webots, used by the run command. # Интерактивный сценарий
def run(args: argparse.Namespace) -> None:
Launched by run.py when the user starts a local simulation but Webots """Guide the user through installing ROS2 Jazzy and building the workspace.
is not installed yet. Проводит пользователя через установку ROS2 Jazzy и сборку workspace.
Отдельное TUI приложение для установки Webots, используемое командой run.
Запускается из run.py когда пользователь запускает локальную симуляцию,
но Webots ещё не установлен.
""" """
header("Локальная установка", "ROS2 Jazzy + сборка проекта")
CSS = SCREEN_CSS choice = ui.select("Установить ROS2 Jazzy?", ["Да, установить", "Нет, выход"],
"Да, установить")
if not choice or choice.startswith("Нет"):
return
def on_mount(self) -> None: # Acquire sudo once, up front, with the masked prompt + keep-alive thread.
self.push_screen( # Получаем sudo один раз, заранее, с маскированным вводом + keep-alive потоком.
LogScreen( if not privilege.ensure_sudo():
f"Installing Webots {_WEBOTS_VERSION}", return
_task_install_webots,
show_progress=True, if not _detect_ubuntu_2404():
), v = ui.select(
self.exit, "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: def register(subparsers: argparse._SubParsersAction) -> None:
"""Register the local-setup subcommand with the CLI argument parser. """Register the local-setup subcommand with the CLI argument parser.
Регистрирует подкоманду local-setup в парсере аргументов командной строки. Регистрирует подкоманду local-setup в парсере аргументов командной строки.
""" """
p = subparsers.add_parser( 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", help="Install ROS2 Jazzy natively and build the project with colcon",
) )
p.set_defaults(func=run) 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 pathlib import Path
from typing import List, Optional from typing import List, Optional
from textual.app import App from cobot import ui, process
from cobot.tui import SCREEN_CSS, InputScreen, LogScreen, PickScreen
from cobot.commands.local_setup import _ros2_env from cobot.commands.local_setup import _ros2_env
_PROJECT_DIR = Path(__file__).parent.parent.parent _PROJECT_DIR = Path(__file__).parent.parent.parent
def _task_rebuild(screen: LogScreen, packages: List[str], symlink: bool) -> None: def _count_packages(packages: List[str], env: dict) -> int:
"""Run colcon build for the selected packages (or all if packages is empty). """Count how many colcon packages will be built so we can show X / total progress.
Streams output to the log and tracks per-package progress. Считает количество пакетов colcon для отображения прогресса X / всего.
Запускает colcon build для выбранных пакетов (или всех если packages пуст).
Транслирует вывод в лог и отслеживает прогресс по каждому пакету.
""" """
try: list_cmd = ["colcon", "list", "--base-paths", "src"]
env = _ros2_env() if packages:
list_cmd += ["--packages-select"] + packages
# Count packages so we can show X / total progress. result = subprocess.run(list_cmd, capture_output=True, text=True,
# Считаем пакеты чтобы показывать X / всего в прогрессе. cwd=_PROJECT_DIR, env=env)
list_cmd = ["colcon", "list", "--base-paths", "src"] return max(len([l for l in result.stdout.splitlines() if l.strip()]), 1)
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)
class _RebuildApp(App[None]): def _rebuild(packages: List[str], symlink: bool) -> None:
"""Rebuild wizard: optionally asks for packages and symlink flag, then runs colcon. """Run colcon build for the selected packages (or all), streaming live output
Мастер пересборки: опционально спрашивает пакеты и флаг symlink, затем запускает colcon. 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]): cmd = ["colcon", "build", "--base-paths", "src"]
super().__init__() if symlink:
# None means "ask the user interactively". cmd.append("--symlink-install")
# None означает "спросить пользователя интерактивно". if packages:
self._packages = packages cmd += ["--packages-select"] + packages
self._symlink = symlink
def on_mount(self) -> None: built = 0
if self._packages is None:
self._ask_packages()
elif self._symlink is None:
self._ask_symlink()
else:
self._start()
def _ask_packages(self) -> None: def _parse(line: str):
self.push_screen( nonlocal built
InputScreen( if "Finished <<<" in line or "Failed <<<" in line:
"rebuild", built += 1
"Packages to rebuild (space-separated, leave empty for all):", return (built / total * 100, f"{built} / {total} пакетов")
"", return None
note="Example: iiwa_controller iiwa_bringup",
),
self._got_packages,
)
def _got_packages(self, value: Optional[str]) -> None: rc = process.run_step(
if value is None: f"colcon build{symlink_label}{pkg_label}",
self.exit() cmd,
return env=env,
self._packages = value.split() if value.strip() else [] cwd=str(_PROJECT_DIR),
if self._symlink is None: total=100.0,
self._ask_symlink() parse_progress=_parse,
else: success_msg="Сборка завершена",
self._start() fail_msg="Сборка завершилась с ошибкой",
)
def _ask_symlink(self) -> None: if rc in (0, -9, -15):
self.push_screen( ui.note("Активируйте окружение: source install/setup.bash")
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(),
)
def register(subparsers: argparse._SubParsersAction) -> None: def register(subparsers: argparse._SubParsersAction) -> None:
@@ -186,7 +95,23 @@ def run(args: argparse.Namespace) -> None:
"""Entry point for the rebuild command. """Entry point for the rebuild command.
Точка входа для команды rebuild. Точка входа для команды rebuild.
""" """
# Convert empty list to None so the TUI asks interactively. packages: Optional[List[str]] = args.packages if args.packages else None
# Преобразуем пустой список в None, чтобы TUI спросил интерактивно.
packages = args.packages if args.packages else None if packages is None:
_RebuildApp(packages, args.symlink).run() 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 argparse
import sys import sys
from dataclasses import dataclass, field from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Callable, List, Optional, Tuple from typing import Any, List, Optional
from ruamel.yaml import YAML 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 _PROJECT_DIR = Path(__file__).parent.parent.parent
_CONFIG_PATH = _PROJECT_DIR / "cobot-setting.yaml" _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" _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, чтобы комментарии и форматирование в конфиге сохранялись. # Используем ruamel.yaml вместо PyYAML, чтобы комментарии и форматирование в конфиге сохранялись.
_yaml = YAML() _yaml = YAML()
_yaml.preserve_quotes = True _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 @dataclass
class _Field: class _Field:
key: str # dot-separated path within the block, e.g. "webots.world" key: str # dot-separated path within the block, e.g. "webots.world"
question: str question: str
default: Any default: Any
note: str = "" 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: def label(self) -> str:
return self.key.split(".")[-1] return self.key.split(".")[-1]
# A group of related fields shown together under one "Configure X?" question.
# Группа связанных полей, показываемая вместе под одним вопросом "Настроить X?".
@dataclass @dataclass
class _Block: class _Block:
yaml_key: str # top-level key in cobot-setting.yaml 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] fields: List[_Field]
@@ -76,15 +66,10 @@ def _build_tool_block() -> Optional[_Block]:
) )
return _Block( return _Block(
yaml_key="tool", yaml_key="tool",
title="Tool / End-effector", title="Инструмент / Захват",
fields=[ fields=[
_Field( _Field("active", "Выберите активный инструмент:", options[0],
"active", note=labels, options=options),
"Выберите активный инструмент:",
options[0],
note=labels,
options=options,
),
], ],
) )
@@ -96,76 +81,83 @@ _BLOCKS: List[_Block] = [
yaml_key="foxglove", yaml_key="foxglove",
title="Foxglove bridge", title="Foxglove bridge",
fields=[ fields=[
_Field("enabled", "Enable Foxglove bridge?", "true", _Field("enabled", "Включить Foxglove bridge?", "true",
note="Start foxglove_bridge alongside the robot node", note="Запускать foxglove_bridge вместе с узлом робота",
options=["true", "false"]), options=["true", "false"]),
_Field("port", "WebSocket port:", "8765", _Field("port", "Порт WebSocket:", "8765",
note="Port Foxglove Studio connects to (default 8765)"), note="Порт, к которому подключается Foxglove Studio (по умолчанию 8765)"),
_Field("address", "Listen address:", "0.0.0.0", _Field("address", "Адрес прослушивания:", "0.0.0.0",
note="0.0.0.0 = all interfaces, 127.0.0.1 = localhost only", note="0.0.0.0 = все интерфейсы, 127.0.0.1 = только localhost",
options=["0.0.0.0", "127.0.0.1"]), options=["0.0.0.0", "127.0.0.1"]),
_Field("use_sim_time", "Use simulation time (/clock)?", "false", _Field("use_sim_time", "Использовать симуляционное время (/clock)?", "false",
note="Subscribe to /clock instead of using wall time", note="Подписываться на /clock вместо системного времени",
options=["false", "true"]), options=["false", "true"]),
_Field("debug", "Enable verbose bridge logging?", "false", _Field("debug", "Подробное логирование bridge?", "false",
options=["false", "true"]), 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( _Block(
yaml_key="planning", yaml_key="planning",
title="MoveIt planning", title="MoveIt планирование",
fields=[ fields=[
_Field("planning_group", "Planning group:", "iiwa_arm", _Field("planning_group", "Группа планирования:", "iiwa_arm",
note="MoveIt planning group as defined in the SRDF"), note="Группа планирования MoveIt из SRDF"),
_Field("default_frame", "Default reference frame:", "base_link"), _Field("default_frame", "Система отсчёта по умолчанию:", "base_link"),
_Field("default_planner", "Default planner:", "ompl", _Field("default_planner", "Планировщик по умолчанию:", "ompl",
options=["ompl", "pilz_industrial_motion_planner", "chomp"]), options=["ompl", "pilz_industrial_motion_planner", "chomp"]),
_Field("planning_attempts", "Planning attempts:", "3"), _Field("planning_attempts", "Попыток планирования:", "3"),
], ],
), ),
_Block( _Block(
yaml_key="digital_twin", yaml_key="digital_twin",
title="Digital twin (Webots / RViz)", title="Цифровой двойник (Webots / RViz)",
fields=[ fields=[
_Field("webots.transform", "Robot transform in Webots scene (x y z, metres):", "-0.25 0 0.79"), _Field("webots.transform", "Трансформ робота в сцене Webots (x y z, метры):", "-0.25 0 0.79"),
_Field("webots.rotation", "Robot rotation in Webots scene (ax ay az angle):", "0 0 1 0"), _Field("webots.rotation", "Поворот робота в сцене Webots (ax ay az угол):", "0 0 1 0"),
_Field("webots.controller_timer", "Webots controller step timer (ms):", "50"), _Field("webots.controller_timer", "Шаг таймера контроллера Webots (мс):", "50"),
], ],
), ),
_Block( _Block(
yaml_key="robot", yaml_key="robot",
title="Robot connection", title="Подключение робота",
fields=[ fields=[
_Field("name", "Robot model name:", "iiwa7"), _Field("name", "Имя модели робота:", "iiwa7"),
_Field("ip", "Robot IP address:", "192.170.10.2", _Field("ip", "IP-адрес робота:", "192.170.10.2",
note="IP of the KUKA controller on the FRI network interface"), note="IP контроллера KUKA на сетевом интерфейсе FRI"),
_Field("port", "FRI port:", "30200"), _Field("port", "Порт FRI:", "30200"),
_Field("command_mode", "Command mode:", "position", _Field("fri_cycle_ms", "Цикл FRI (мс):", "10",
note="position = joint position control, torque = joint torque control", note="5 мс = 200 Гц, 10 мс = 100 Гц",
options=["position", "torque"]),
_Field("fri_cycle_ms", "FRI cycle time (ms):", "10",
note="5 ms = 200 Hz, 10 ms = 100 Hz",
options=["10", "5"]), options=["10", "5"]),
_Field("active_controller", "Active ROS controller:", "jtc", _Field("active_controller", "Активный ROS-контроллер:", "jtc",
note="jtc = JointTrajectoryController (MoveIt), forward = ForwardCommandController", note="jtc = JointTrajectoryController (MoveIt), forward = ForwardCommandController",
options=["jtc", "forward"]), options=["jtc", "forward"]),
_Field("joint_position_tau", "Position EMA filter tau (s):", "0.04", _Field("joint_position_tau", "EMA tau фильтра положения (с):", "0.04",
note="Smooths position commands before sending to FRI"), note="Сглаживает команды положения перед отправкой в FRI"),
_Field("joint_velocity_tau", "Velocity EMA filter tau (s):", "0.01", _Field("joint_velocity_tau", "EMA tau фильтра скорости (с):", "0.01",
note="Removes spikes from finite-difference velocity estimation"), 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: def _coerce(value: str, original: Any) -> Any:
"""Convert a string value to match the type of the original YAML value (bool, int, float, str). """Convert a string value to match the type of the original YAML value.
Преобразует строковое значение к типу исходного значения YAML (bool, int, float, str). Преобразует строковое значение к типу исходного значения YAML.
""" """
if isinstance(original, bool): if isinstance(original, bool):
return value.lower() == "true" return value.lower() == "true"
@@ -182,11 +174,9 @@ def _coerce(value: str, original: Any) -> Any:
return value 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: 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. """Return the value at a dot-separated path inside a nested YAML mapping, or None.
Возвращает значение по пути с точками внутри вложенного YAML-словаря, или None если отсутствует. Возвращает значение по пути с точками внутри вложенного YAML-словаря, или None.
""" """
keys = path.split(".") keys = path.split(".")
cur = mapping cur = mapping
@@ -197,172 +187,108 @@ def _get_nested(mapping: Any, path: str) -> Any:
return cur return cur
# Write a value into a nested YAML mapping using a dot-separated key. def _infer(value: str) -> Any:
# Записываем значение в вложенный YAML-словарь по ключу с точками. """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: 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. """Set the value at a dot-separated path, creating missing intermediate maps.
Устанавливает значение по пути с точками во вложенном YAML-словаре, приводя тип к исходному.
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(".") keys = path.split(".")
cur = mapping cur = mapping
for k in keys[:-1]: for k in keys[:-1]:
if k not in cur or cur[k] is None:
cur[k] = {}
cur = cur[k] cur = cur[k]
original = cur[keys[-1]] leaf = keys[-1]
cur[keys[-1]] = _coerce(value, original) 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: def _load_config() -> Any:
"""Load cobot-setting.yaml with ruamel.yaml, preserving comments and key order. """Load cobot-setting.yaml preserving comments and key order.
Загружает cobot-setting.yaml с помощью ruamel.yaml, сохраняя комментарии и порядок ключей. Загружает cobot-setting.yaml, сохраняя комментарии и порядок ключей.
""" """
with open(_CONFIG_PATH, "r", encoding="utf-8") as fh: with open(_CONFIG_PATH, "r", encoding="utf-8") as fh:
return _yaml.load(fh) return _yaml.load(fh)
# Write the modified config back to disk preserving comments and formatting.
# Записываем изменённый конфиг обратно на диск, сохраняя комментарии и форматирование.
def _save_config(data: Any) -> None: def _save_config(data: Any) -> None:
"""Write the modified YAML data back to cobot-setting.yaml, preserving comments. """Write the modified YAML back to cobot-setting.yaml, preserving comments.
Записывает изменённые данные YAML обратно в cobot-setting.yaml, сохраняя комментарии. Записывает изменённый YAML обратно в cobot-setting.yaml, сохраняя комментарии.
""" """
with open(_CONFIG_PATH, "w", encoding="utf-8") as fh: with open(_CONFIG_PATH, "w", encoding="utf-8") as fh:
_yaml.dump(data, 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: def register(subparsers: argparse._SubParsersAction) -> None:
p = subparsers.add_parser("robot-setup", help="Configure cobot-setting.yaml interactively") p = subparsers.add_parser("robot-setup", help="Configure cobot-setting.yaml interactively")
p.set_defaults(func=run) p.set_defaults(func=run)
def run(args: argparse.Namespace) -> None: def run(args: argparse.Namespace) -> None:
from rich.console import Console ui.header("Настройка робота", "cobot-setting.yaml")
console = Console()
if not _CONFIG_PATH.exists(): if not _CONFIG_PATH.exists():
console.print(f"[red]Config not found:[/red] {_CONFIG_PATH}") ui.error(f"Конфиг не найден: {_CONFIG_PATH}")
sys.exit(1) sys.exit(1)
data = _load_config() data = _load_config()
@@ -372,46 +298,37 @@ def run(args: argparse.Namespace) -> None:
data["tool"] = {"active": "patron"} data["tool"] = {"active": "patron"}
tool_block = _build_tool_block() 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) if not _run_wizard(data, blocks):
wizard.run()
if not wizard.did_save:
return return
# Применяем выбранный инструмент: перезаписываем tool_active.xacro и iiwa7.srdf # Применяем выбранный инструмент: перезаписываем tool_active.xacro и iiwa7.srdf
active_tool = str(data["tool"].get("active", "patron")) active_tool = str(data["tool"].get("active", "patron"))
if _TOOLS_YAML.exists(): if not _TOOLS_YAML.exists():
try: ui.info(f"[yellow]tools.yaml не найден ({_TOOLS_YAML}) — пропуск применения инструмента[/yellow]")
registry = _load_tools_registry() return
if active_tool not in registry:
raise ValueError(
f"Unknown tool '{active_tool}'. Available: {', '.join(registry)}"
)
tool_cfg = dict(registry[active_tool])
import sys as _sys try:
_sys.path.insert(0, str(_PROJECT_DIR / "src" / "iiwa_utils")) registry = _load_tools_registry()
from iiwa_utils.tool_manager import apply_tool if active_tool not in registry:
apply_tool( raise ValueError(f"Неизвестный инструмент '{active_tool}'. Доступны: {', '.join(registry)}")
tool_cfg=tool_cfg, tool_cfg = dict(registry[active_tool])
xacro_out_path=_TOOL_ACTIVE_XACRO,
srdf_path=_SRDF_PATH,
)
# Синхронизируем planning.pose_link с tcp_link выбранного инструмента sys.path.insert(0, str(_PROJECT_DIR / "src" / "iiwa_utils"))
tcp_link = tool_cfg.get("tcp_link", "link_ee") from iiwa_utils.tool_manager import apply_tool
if "planning" in data: apply_tool(tool_cfg=tool_cfg, xacro_out_path=_TOOL_ACTIVE_XACRO, srdf_path=_SRDF_PATH)
data["planning"]["pose_link"] = tcp_link
_save_config(data)
console.print( # Синхронизируем planning.pose_link с tcp_link выбранного инструмента
f"[green]✓[/green] Tool [bold]{active_tool}[/bold] applied: " tcp_link = tool_cfg.get("tcp_link", "link_ee")
f"tool_active.xacro, iiwa7.srdf updated, " if "planning" in data:
f"planning.pose_link → [bold]{tcp_link}[/bold]." data["planning"]["pose_link"] = tcp_link
) _save_config(data)
except Exception as exc:
console.print(f"[red]Tool apply failed:[/red] {exc}") ui.info(
else: f"[green]✓[/green] Инструмент [bold]{active_tool}[/bold] применён: "
console.print(f"[yellow]tools.yaml not found at {_TOOLS_YAML} — skipping tool apply[/yellow]") 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 argparse
import os import os
import signal
import shutil import shutil
import socket import socket
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from typing import Callable, List, Optional from typing import List, Optional
from textual.app import App from cobot import process, ui
from cobot import privilege
from cobot.tui import SCREEN_CSS, LogScreen, PickScreen, RunScreen from cobot.ui import done, header
from cobot.commands.local_setup import webots_installed, WebotsInstallApp, _WEBOTS_VERSION from cobot.commands.local_setup import (
_WEBOTS_VERSION,
build_workspace,
install_webots,
webots_installed,
)
_PROJECT_DIR = Path(__file__).parent.parent.parent _PROJECT_DIR = Path(__file__).parent.parent.parent
_CONFIG_PATH = _PROJECT_DIR / "cobot-setting.yaml" _CONFIG_PATH = _PROJECT_DIR / "cobot-setting.yaml"
@@ -32,15 +36,11 @@ _CONTAINER_CONTROLLER = "lwc-controller"
_CONTAINER_WEBOTS = "lwc-webots" _CONTAINER_WEBOTS = "lwc-webots"
# Named Docker volume that stores the Webots asset cache between container runs. # 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 между запусками контейнера. # Именованный Docker volume для хранения кэша ассетов Webots между запусками контейнера.
# Без него Webots заново скачивает все 3D-ассеты из интернета при каждом запуске.
_WEBOTS_CACHE_VOLUME = "lwc-webots-cache" _WEBOTS_CACHE_VOLUME = "lwc-webots-cache"
# Candidates checked in order - for the controller the webots image is a valid fallback # Candidates checked in order - for the controller the webots image is a valid fallback.
# because it already contains all controller packages too. # Кандидаты проверяются по порядку - для контроллера образ webots является допустимым запасным.
# Кандидаты проверяются по порядку - для контроллера образ webots является допустимым запасным,
# так как он уже содержит все пакеты контроллера.
_CONTROLLER_IMAGES = [ _CONTROLLER_IMAGES = [
"lwc-local:ros-iiwa7-jazzy", "lwc-local:ros-iiwa7-jazzy",
"evilfisru/lwc:iiwa-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: def _detect_webots_home() -> str:
"""Return the WEBOTS_HOME path for the locally installed Linux Webots. """Return the WEBOTS_HOME path for the locally installed Linux Webots, or "".
Возвращает путь WEBOTS_HOME для локально установленного Linux Webots, или "".
Validates that the candidate directory contains the Linux 'webots' binary
Возвращает путь WEBOTS_HOME для локально установленного Linux Webots.
""" """
def _is_linux_webots(home: str) -> bool: 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() return (Path(home) / "webots").is_file()
if "WEBOTS_HOME" in os.environ: if "WEBOTS_HOME" in os.environ:
home = os.environ["WEBOTS_HOME"] home = os.environ["WEBOTS_HOME"]
if _is_linux_webots(home): return home if _is_linux_webots(home) else ""
return home
return ""
if _WEBOTS_DEFAULT_HOME.is_dir() and _is_linux_webots(str(_WEBOTS_DEFAULT_HOME)): if _WEBOTS_DEFAULT_HOME.is_dir() and _is_linux_webots(str(_WEBOTS_DEFAULT_HOME)):
return str(_WEBOTS_DEFAULT_HOME) return str(_WEBOTS_DEFAULT_HOME)
webots_bin = shutil.which("webots") webots_bin = shutil.which("webots")
@@ -116,10 +76,8 @@ def _detect_webots_home() -> str:
return "" 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: 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. Возвращает "nvidia", "mesa" или "software" в зависимости от доступных драйверов GPU.
""" """
if shutil.which("nvidia-smi"): if shutil.which("nvidia-smi"):
@@ -130,11 +88,9 @@ def _detect_gpu() -> str:
return "software" return "software"
# List all Docker images currently available on this machine.
# Получаем список всех Docker-образов доступных на этой машине.
def _docker_images() -> set: def _docker_images() -> set:
"""Return the set of "repository:tag" strings for all locally available Docker images. """Return the set of "repository:tag" strings for all local Docker images.
Возвращает множество строк "репозиторий:тег" для всех локально доступных Docker-образов. Возвращает множество строк "репозиторий:тег" для всех локальных Docker-образов.
""" """
r = subprocess.run( r = subprocess.run(
["docker", "images", "--format", "{{.Repository}}:{{.Tag}}"], ["docker", "images", "--format", "{{.Repository}}:{{.Tag}}"],
@@ -143,11 +99,9 @@ def _docker_images() -> set:
return set(r.stdout.strip().splitlines()) 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]: def _find_image(candidates: List[str]) -> Optional[str]:
"""Return the first candidate image that exists locally, or None if none are available. """Return the first candidate image that exists locally, or None.
Возвращает первый образ-кандидат, присутствующий локально, или None если ни один не найден. Возвращает первый образ-кандидат, присутствующий локально, или None.
""" """
available = _docker_images() available = _docker_images()
for img in candidates: for img in candidates:
@@ -156,82 +110,13 @@ def _find_image(candidates: List[str]) -> Optional[str]:
return None return None
# Build the ROS2 project locally with colcon. Used when launching in local mode # Local (non-Docker) launch
# and the install/ directory does not exist yet. # Локальный (не Docker) запуск
# Собираем ROS2-проект локально с помощью colcon. Используется при запуске в локальном режиме, def _run_local(mode: str) -> None:
# если директория install/ ещё не существует. """Launch iiwa.launch.py natively. The whole ros2 launch tree runs in its own
def _task_build(screen: LogScreen) -> None: session so a single Ctrl-C tears down every node cleanly.
"""Worker function that runs inside LogScreen. Counts packages, then runs colcon build Запускает iiwa.launch.py нативно. Всё дерево ros2 launch работает в своей сессии,
with release mixin and updates progress as each package finishes. поэтому один Ctrl-C аккуратно завершает каждый узел.
Рабочая функция внутри 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, затем транслирует вывод до завершения процесса или его остановки.
""" """
config = str(_CONFIG_PATH) config = str(_CONFIG_PATH)
ros_cmd = f"ros2 launch iiwa_bringup iiwa.launch.py setting:={config}" 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}" f"{ros_cmd}"
) )
label = "Webots simulator" if mode == "webots" else "Controller" label = "симулятор Webots" if mode == "webots" else "контроллер"
screen.write(f"[bold]Launching {label} (local)[/bold]") header(f"Запуск: {label} (локально)")
screen.write(f"[dim]{ros_cmd}[/dim]") ui.note(ros_cmd)
if webots_home: if webots_home:
screen.write(f"[dim]WEBOTS_HOME: {webots_home}[/dim]") ui.note(f"WEBOTS_HOME: {webots_home}")
screen.write("") ui.note("Нажмите Ctrl-C чтобы остановить")
proc = subprocess.Popen( rc = process.stream(
["bash", "-c", full_cmd], ["bash", "-c", full_cmd],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, cwd=str(_PROJECT_DIR),
text=True, cwd=_PROJECT_DIR, new_session=True,
start_new_session=True,
) )
screen.set_proc(proc) done(rc in (0, -2, -15, 130), "Остановлено")
# 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)
# Start the ROS2 launch file inside a Docker container. # Docker launch
# For Webots mode we also forward X11 and GPU access so the simulator window can appear on screen. # Запуск в Docker
# Запускаем launch-файл ROS2 внутри Docker-контейнера. def _run_docker(image: str, mode: str, gpu: str) -> None:
# Для режима Webots также пробрасываем X11 и доступ к GPU, чтобы окно симулятора появилось на экране. """Launch iiwa.launch.py inside a Docker container, forwarding X11/GPU for Webots.
def _task_run_docker(screen: RunScreen, image: str, mode: str, gpu: str) -> None: The container is stopped with ``docker kill`` on Ctrl-C.
"""Worker function that runs inside RunScreen. Builds the docker run command with the Запускает iiwa.launch.py внутри Docker-контейнера, пробрасывая X11/GPU для Webots.
appropriate GPU/X11 flags for Webots, then streams container output until stopped or exited. Контейнер останавливается через ``docker kill`` по Ctrl-C.
Рабочая функция внутри RunScreen. Формирует команду docker run с нужными флагами GPU/X11
для Webots, затем транслирует вывод контейнера до остановки или завершения.
""" """
container = _CONTAINER_WEBOTS if mode == "webots" else _CONTAINER_CONTROLLER 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": if mode == "webots":
ros_cmd += " simulate:=1" 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) subprocess.run(["docker", "rm", "-f", container], capture_output=True)
cmd = [ cmd = [
@@ -316,15 +178,11 @@ def _task_run_docker(screen: RunScreen, image: str, mode: str, gpu: str) -> None
] ]
if mode == "webots": if mode == "webots":
# Allow the container to open windows on the host display.
# Разрешаем контейнеру открывать окна на дисплее хоста.
subprocess.run(["xhost", "+local:docker"], capture_output=True) subprocess.run(["xhost", "+local:docker"], capture_output=True)
cmd += [ cmd += [
"-e", f"DISPLAY={os.environ.get('DISPLAY', ':0')}", "-e", f"DISPLAY={os.environ.get('DISPLAY', ':0')}",
"-e", "QT_X11_NO_MITSHM=1", "-e", "QT_X11_NO_MITSHM=1",
"-v", "/tmp/.X11-unix:/tmp/.X11-unix:rw", "-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", "-v", f"{_WEBOTS_CACHE_VOLUME}:/root/.cache/Cyberbotics/Webots",
] ]
if gpu == "nvidia": 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", "-e", "NVIDIA_DRIVER_CAPABILITIES=graphics,utility,compute",
] ]
elif gpu == "mesa": elif gpu == "mesa":
# Pass through the DRI device for Intel/AMD hardware acceleration.
# Пробрасываем DRI-устройство для аппаратного ускорения Intel/AMD.
cmd += ["--device", "/dev/dri"] cmd += ["--device", "/dev/dri"]
else: else:
# No GPU found - fall back to software rendering via llvmpipe.
# GPU не найден - используем программный рендеринг через llvmpipe.
cmd += [ cmd += [
"-e", "LIBGL_ALWAYS_SOFTWARE=1", "-e", "LIBGL_ALWAYS_SOFTWARE=1",
"-e", "GALLIUM_DRIVER=llvmpipe", "-e", "GALLIUM_DRIVER=llvmpipe",
] ]
# Mount the config file so the container uses our local cobot-setting.yaml.
# Монтируем конфиг-файл, чтобы контейнер использовал наш локальный cobot-setting.yaml.
if _CONFIG_PATH.exists(): if _CONFIG_PATH.exists():
cmd += ["-v", f"{_CONFIG_PATH}:{_CONFIG_IN_CONTAINER}:ro"] 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 = { _GPU_LABELS = {
"nvidia": "NVIDIA GPU", "nvidia": "NVIDIA GPU",
"mesa": "Intel/AMD DRI (Mesa)", "mesa": "Intel/AMD DRI (Mesa)",
"software": "Software rendering (llvmpipe)", "software": "Программный рендеринг (llvmpipe)",
} }
label = "Webots simulator" if mode == "webots" else "Controller" label = "симулятор Webots" if mode == "webots" else "контроллер"
screen.write(f"[bold]Launching {label} in Docker[/bold]") header(f"Запуск: {label} в Docker")
screen.write(f"[dim]Image: {image}[/dim]") ui.note(f"Образ: {image}")
if mode == "webots": if mode == "webots":
screen.write(f"[dim]GPU: {_GPU_LABELS.get(gpu, gpu)}[/dim]") ui.note(f"GPU: {_GPU_LABELS.get(gpu, gpu)}")
screen.write("") ui.note("Нажмите Ctrl-C чтобы остановить")
proc = subprocess.Popen( rc = process.stream(
cmd, cmd,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, kill_fn=lambda: subprocess.run(["docker", "kill", container], capture_output=True),
text=True,
) )
screen.set_proc(proc) done(rc in (0, -2, -15, 130), "Остановлено")
# 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)
# 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: def _local_flow(args: argparse.Namespace) -> None:
"""Interactive flow for local (non-Docker) launch. Checks Webots, ROS2, and build state, """Interactive flow for local launch: check Webots/ROS2/build, then run.
offers to install/build missing pieces, then starts RunScreen. Интерактивный сценарий локального запуска: проверка Webots/ROS2/сборки, затем запуск.
Интерактивный сценарий для локального (не Docker) запуска. Проверяет Webots, ROS2 и состояние
сборки, предлагает установить/собрать недостающее, затем запускает RunScreen.
""" """
mode_v = _ask( mode_v = ui.select(
"Run local", "Что запустить?",
"What do you want to launch?", ["Контроллер", "Симулятор Webots"],
["Controller", "Webots simulator"], "Контроллер",
"Controller",
) )
if mode_v is None: if mode_v is None:
return 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(): if mode == "webots" and not webots_installed():
v = _ask( if not ui.confirm(f"Webots {_WEBOTS_VERSION} не установлен. Установить сейчас?",
"Webots not found", default=True):
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"):
return return
ok = WebotsInstallApp().run() if not privilege.ensure_sudo() or not install_webots():
if not ok:
return return
# Check ROS2 Jazzy
if not _JAZZY_DIR.is_dir(): if not _JAZZY_DIR.is_dir():
v = _ask( if ui.confirm("ROS2 Jazzy не установлен. Запустить local-setup?", default=True):
"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"):
from cobot.commands.local_setup import run as _local_setup from cobot.commands.local_setup import run as _local_setup
_local_setup(args) _local_setup(args)
return return
# Check project built
if not (_INSTALL_DIR / "setup.bash").exists(): if not (_INSTALL_DIR / "setup.bash").exists():
v = _ask( if not ui.confirm("Проект ещё не собран. Собрать сейчас?", default=True):
"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"):
return return
ok = _BuildApp().run() if not privilege.ensure_sudo() or not build_workspace():
if not ok:
return return
label = "Webots simulator" if mode == "webots" else "Controller" _run_local(mode)
_RunApp(f"Running {label} — local", lambda s: _task_run_local(s, mode)).run()
# 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: def _docker_flow(args: argparse.Namespace) -> None:
"""Interactive flow for Docker launch. Finds the best available image, detects the GPU """Interactive flow for Docker launch: pick an image, detect GPU, then run.
for Webots mode, then starts RunScreen with the docker run task. Интерактивный сценарий запуска в Docker: выбор образа, определение GPU, затем запуск.
Интерактивный сценарий для запуска в Docker. Находит лучший доступный образ, определяет GPU
для режима Webots, затем запускает RunScreen с задачей docker run.
""" """
if not shutil.which("docker"): if not shutil.which("docker"):
from rich.console import Console ui.error("Docker не установлен или отсутствует в PATH.")
Console().print("[red]Error:[/red] Docker is not installed or not on PATH.")
return return
mode_v = _ask( mode_v = ui.select(
"Run in Docker", "Что запустить?",
"What do you want to launch?", ["Контроллер", "Симулятор Webots"],
["Controller", "Webots simulator"], "Контроллер",
"Controller",
) )
if mode_v is None: if mode_v is None:
return 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 candidates = _WEBOTS_IMAGES if mode == "webots" else _CONTROLLER_IMAGES
image = _find_image(candidates) image = _find_image(candidates)
if image is None: if image is None:
# No image available - offer to run docker-setup to get one. what = "Webots" if mode == "webots" else "контроллера или Webots"
# Образ не найден - предлагаем запустить docker-setup чтобы его получить. if ui.confirm(f"Docker-образ для {what} не найден. Запустить docker-setup?",
what = "Webots" if mode == "webots" else "controller or Webots" default=True):
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"):
from cobot.commands.docker_setup import run as _docker_setup from cobot.commands.docker_setup import run as _docker_setup
_docker_setup(args) _docker_setup(args)
return return
# Only detect GPU for Webots - the controller does not need a display.
# GPU определяем только для Webots - контроллеру дисплей не нужен.
gpu = _detect_gpu() if mode == "webots" else "software" gpu = _detect_gpu() if mode == "webots" else "software"
_run_docker(image, mode, gpu)
label = "Webots simulator" if mode == "webots" else "Controller"
_RunApp(
f"Running {label} — Docker",
lambda s: _task_run_docker(s, image, mode, gpu),
).run()
def register(subparsers: argparse._SubParsersAction) -> None: def register(subparsers: argparse._SubParsersAction) -> None:
@@ -544,17 +313,14 @@ def run(args: argparse.Namespace) -> None:
elif mode == "docker": elif mode == "docker":
_docker_flow(args) _docker_flow(args)
else: else:
# No mode given - ask the user how they want to run. v = ui.select(
# Режим не указан - спрашиваем пользователя как он хочет запустить. "Как запустить проект?",
v = _ask( ["Локально (нативный ROS2)", "Docker"],
"Run", "Локально (нативный ROS2)",
"How do you want to run the project?",
["Local (native ROS2)", "Docker"],
"Local (native ROS2)",
) )
if v is None: if v is None:
return return
if v.startswith("Local"): if v.startswith("Локально"):
_local_flow(args) _local_flow(args)
else: else:
_docker_flow(args) _docker_flow(args)
+14 -59
View File
@@ -1,7 +1,6 @@
import argparse 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. # Import each sub-command's run() so we can call them in sequence.
# Импортируем run() каждой подкоманды, чтобы вызывать их по порядку. # Импортируем 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.docker_setup import run as _docker_setup
from cobot.commands.local_setup import run as _local_setup from cobot.commands.local_setup import run as _local_setup
from cobot.commands.robot_setup import run as _robot_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): def register(subparsers):
@@ -54,44 +19,34 @@ def register(subparsers):
def run(args: argparse.Namespace) -> None: 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. # Step 1 - documentation server.
# Шаг 1 - сервер документации. # Шаг 1 - сервер документации.
v = _ask("Step 1 of 3", "Set up the documentation server?", ["Yes", "No"], "Yes") if ui.confirm("Шаг 1/3 — настроить сервер документации?", default=True):
if v is None:
return
if v == "Yes":
_doc_setup(args) _doc_setup(args)
# Step 2 - build environment: local ROS2 or Docker. # Step 2 - build environment: local ROS2 or Docker.
# Шаг 2 - среда сборки: локальный ROS2 или Docker. # Шаг 2 - среда сборки: локальный ROS2 или Docker.
v = _ask( env_choice = ui.select(
"Step 2 of 3", "Шаг 2/3 — как настроить среду сборки?",
"How do you want to set up the build environment?",
[ [
"local-setup — install ROS2 Jazzy on this machine and build with colcon", "local-setup — установить ROS2 Jazzy на эту машину и собрать через colcon",
"docker-setup — build a Docker image with ROS2 Jazzy pre-installed", "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 return
if v.startswith("local"): if env_choice.startswith("local"):
_local_setup(args) _local_setup(args)
else: else:
_docker_setup(args) _docker_setup(args)
# Step 3 - robot parameters in cobot-setting.yaml. # Step 3 - robot parameters in cobot-setting.yaml.
# Шаг 3 - параметры робота в cobot-setting.yaml. # Шаг 3 - параметры робота в cobot-setting.yaml.
v = _ask( if ui.confirm("Шаг 3/3 — настроить параметры робота (cobot-setting.yaml)?", default=True):
"Step 3 of 3",
"Configure robot parameters (cobot-setting.yaml)?",
["Yes", "No"],
"Yes",
)
if v is None:
return
if v == "Yes":
_robot_setup(args) _robot_setup(args)
+55 -119
View File
@@ -4,134 +4,70 @@ import argparse
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from textual.app import App from cobot import process, ui
from cobot.ui import done
from cobot.tui import SCREEN_CSS, LogScreen from cobot.process import StepProgress
_PROJECT_DIR = Path(__file__).parent.parent.parent _PROJECT_DIR = Path(__file__).parent.parent.parent
# Pull the latest commits from the remote and reinstall the cobot CLI in one go. def _git(*args: str) -> str:
# Progress bar: fetch (0-30%), pull (30-80%), reinstall (80-100%). """Run a git command in the project dir and return its stripped stdout.
# Скачиваем последние коммиты с удалённого репозитория и переустанавливаем cobot CLI за один раз. Запускает git-команду в директории проекта и возвращает обрезанный stdout.
# Прогресс-бар: 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.
""" """
try: return subprocess.check_output(["git", *args], cwd=_PROJECT_DIR, text=True).strip()
# 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)
class _UpdateApp(App[None]): def _update() -> None:
"""Minimal Textual app that opens a LogScreen running _task_update and exits when it closes. """Fetch the current branch, show incoming commits, pull, and reinstall the cobot CLI.
Минимальное Textual-приложение, открывающее LogScreen с _task_update и завершающееся при закрытии. 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: behind = _git("rev-list", f"HEAD..origin/{branch}", "--count")
self.push_screen( if behind == "0":
LogScreen("Updating project", _task_update, show_progress=True), p.set(100, "Уже актуально")
lambda _: self.exit(), 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: def register(subparsers: argparse._SubParsersAction) -> None:
@@ -140,4 +76,4 @@ def register(subparsers: argparse._SubParsersAction) -> None:
def run(args: argparse.Namespace) -> 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`` достаёт каждый);
иначе группа процессов (SIGTERMSIGKILL) или сам процесс.
"""
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( setup(
name="lightweight-cobot", name="lightweight-cobot",
version="2026.05.31", version="2026.06.11",
description="CLI tool for installing, configuring and managing the ROS 2 cobot workspace", description="CLI tool for installing, configuring and managing the ROS 2 cobot workspace",
packages=find_packages(), packages=find_packages(),
python_requires=">=3.11", python_requires=">=3.11",
install_requires=[ install_requires=[
"textual", "rich",
"ruamel.yaml", "ruamel.yaml",
], ],
entry_points={ entry_points={