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:
+10
-1
@@ -1,5 +1,4 @@
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
# Import each command module so we can register its subparser.
|
||||
# Импортируем каждый модуль команды, чтобы зарегистрировать его подпарсер.
|
||||
@@ -96,7 +95,17 @@ def main():
|
||||
_register_commands(subparsers)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# 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):
|
||||
|
||||
+25
-74
@@ -3,11 +3,10 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
from typing import List
|
||||
|
||||
from textual.app import App
|
||||
|
||||
from cobot.tui import SCREEN_CSS, LogScreen, MultiPickScreen
|
||||
from cobot import ui
|
||||
from cobot.ui import header, done
|
||||
|
||||
_PROJECT_DIR = Path(__file__).parent.parent.parent
|
||||
|
||||
@@ -19,80 +18,21 @@ _DIR_MAP = {
|
||||
}
|
||||
|
||||
|
||||
def _task_clean(screen: LogScreen, dirs: List[str]) -> None:
|
||||
"""Delete the selected top-level directories.
|
||||
Удаляет выбранные директории верхнего уровня.
|
||||
def _clean(dirs: List[str]) -> None:
|
||||
"""Delete the selected top-level directories, printing the outcome of each.
|
||||
Удаляет выбранные директории верхнего уровня, печатая результат по каждой.
|
||||
"""
|
||||
try:
|
||||
screen.write("[bold]Cleaning build artifacts[/bold]\n")
|
||||
total = len(dirs)
|
||||
for i, label in enumerate(dirs):
|
||||
if screen.is_stopped():
|
||||
return
|
||||
screen.set_progress(i / total * 100, f"Removing {label}...")
|
||||
header("Очистка артефактов сборки")
|
||||
removed = False
|
||||
for label in dirs:
|
||||
path = _DIR_MAP[label]
|
||||
if path.exists():
|
||||
shutil.rmtree(path)
|
||||
screen.write(f"[green][ok][/green] Removed {label}")
|
||||
ui.info(f" [green]✓[/green] Удалено {label}")
|
||||
removed = True
|
||||
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:
|
||||
self._ask_dirs()
|
||||
|
||||
def _ask_dirs(self) -> None:
|
||||
self.push_screen(
|
||||
MultiPickScreen(
|
||||
"clean",
|
||||
"Which directories to delete?",
|
||||
_DIR_OPTIONS,
|
||||
note="Space — toggle · Enter — confirm",
|
||||
),
|
||||
self._got_dirs,
|
||||
)
|
||||
|
||||
def _got_dirs(self, dirs: Optional[List[str]]) -> None:
|
||||
if not dirs:
|
||||
self.exit()
|
||||
return
|
||||
self._start(dirs)
|
||||
|
||||
def _start(self, dirs: List[str]) -> None:
|
||||
self.push_screen(
|
||||
LogScreen(
|
||||
"Cleaning",
|
||||
lambda s: _task_clean(s, dirs),
|
||||
show_progress=True,
|
||||
),
|
||||
lambda _: self.exit(),
|
||||
)
|
||||
ui.info(f" [dim]Нет:[/dim] {label}")
|
||||
done(True, "Очищено" if removed else "Нечего удалять")
|
||||
|
||||
|
||||
def register(subparsers: argparse._SubParsersAction) -> None:
|
||||
@@ -114,4 +54,15 @@ def run(args: argparse.Namespace) -> None:
|
||||
"""Entry point for the clean command.
|
||||
Точка входа для команды clean.
|
||||
"""
|
||||
_CleanApp(all_dirs=(getattr(args, "target", None) == "all")).run()
|
||||
if getattr(args, "target", None) == "all":
|
||||
_clean(_DIR_OPTIONS)
|
||||
return
|
||||
|
||||
dirs = ui.multiselect(
|
||||
"Какие директории удалить?",
|
||||
_DIR_OPTIONS,
|
||||
note="Space — отметить · Enter — подтвердить",
|
||||
)
|
||||
if not dirs:
|
||||
return
|
||||
_clean(dirs)
|
||||
|
||||
+96
-202
@@ -4,110 +4,82 @@ import argparse
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Callable
|
||||
|
||||
from textual.app import App
|
||||
|
||||
from cobot.tui import SCREEN_CSS, LogScreen, PickScreen
|
||||
from cobot import ui
|
||||
from cobot import privilege
|
||||
from cobot.ui import done
|
||||
from cobot.process import StepProgress
|
||||
|
||||
_PROJECT_DIR = Path(__file__).parent.parent.parent
|
||||
|
||||
Log = Callable[[str], None]
|
||||
|
||||
# Stop and remove all Docker containers whose name contains "lwc".
|
||||
# Останавливаем и удаляем все Docker-контейнеры, чьё имя содержит "lwc".
|
||||
def _stop_docker_containers(write) -> None:
|
||||
|
||||
def _stop_docker_containers(log: Log) -> None:
|
||||
"""Stop and force-remove all Docker containers whose name contains "lwc".
|
||||
Останавливает и принудительно удаляет все Docker-контейнеры с "lwc" в имени.
|
||||
"""
|
||||
write("[cyan][*][/cyan] Stopping Docker containers...")
|
||||
log("[cyan]▸[/cyan] Остановка Docker-контейнеров...")
|
||||
result = subprocess.run(
|
||||
["docker", "ps", "-a", "--filter", "name=lwc", "--format", "{{.Names}}"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
containers = [c for c in result.stdout.strip().splitlines() if c]
|
||||
if not containers:
|
||||
write("[dim]No project containers found.[/dim]")
|
||||
log("[dim]Контейнеры проекта не найдены.[/dim]")
|
||||
return
|
||||
for name in containers:
|
||||
subprocess.run(["docker", "rm", "-f", name], capture_output=True)
|
||||
write(f"[green][ok][/green] Removed container: {name}")
|
||||
log(f"[green]✓[/green] Удалён контейнер: {name}")
|
||||
|
||||
|
||||
# Remove all Docker images whose repository or tag contains "lwc".
|
||||
# Удаляем все Docker-образы, репозиторий или тег которых содержит "lwc".
|
||||
def _remove_docker_images(write) -> None:
|
||||
def _remove_docker_images(log: Log) -> None:
|
||||
"""Force-remove all local Docker images whose name or tag contains "lwc".
|
||||
Принудительно удаляет все локальные Docker-образы с "lwc" в имени или теге.
|
||||
"""
|
||||
write("[cyan][*][/cyan] Removing Docker images...")
|
||||
log("[cyan]▸[/cyan] Удаление Docker-образов...")
|
||||
result = subprocess.run(
|
||||
["docker", "images", "--format", "{{.Repository}}:{{.Tag}}"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
project_images = [
|
||||
img for img in result.stdout.strip().splitlines()
|
||||
if "lwc" in img.lower()
|
||||
]
|
||||
project_images = [img for img in result.stdout.strip().splitlines() if "lwc" in img.lower()]
|
||||
if not project_images:
|
||||
write("[dim]No project images found.[/dim]")
|
||||
log("[dim]Образы проекта не найдены.[/dim]")
|
||||
return
|
||||
for img in project_images:
|
||||
subprocess.run(["docker", "rmi", "-f", img], capture_output=True)
|
||||
write(f"[green][ok][/green] Removed image: {img}")
|
||||
log(f"[green]✓[/green] Удалён образ: {img}")
|
||||
|
||||
|
||||
# Remove the Docker volume that stores the Webots asset cache.
|
||||
# Удаляем Docker volume с кэшем ассетов Webots.
|
||||
def _remove_webots_volume(write) -> None:
|
||||
"""Remove the lwc-webots-cache Docker volume if it exists. Skips silently if absent.
|
||||
Удаляет Docker volume lwc-webots-cache если он существует. Молча пропускает если отсутствует.
|
||||
def _remove_webots_volume(log: Log) -> None:
|
||||
"""Remove the lwc-webots-cache Docker volume if it exists.
|
||||
Удаляет Docker volume lwc-webots-cache если он существует.
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["docker", "volume", "inspect", "lwc-webots-cache"],
|
||||
capture_output=True,
|
||||
)
|
||||
result = subprocess.run(["docker", "volume", "inspect", "lwc-webots-cache"], capture_output=True)
|
||||
if result.returncode != 0:
|
||||
write("[dim]Webots cache volume not found, skipping.[/dim]")
|
||||
log("[dim]Volume кэша Webots не найден, пропускаем.[/dim]")
|
||||
return
|
||||
subprocess.run(["docker", "volume", "rm", "lwc-webots-cache"], capture_output=True)
|
||||
write("[green][ok][/green] Removed Docker volume: lwc-webots-cache")
|
||||
log("[green]✓[/green] Удалён Docker volume: lwc-webots-cache")
|
||||
|
||||
|
||||
# Remove ROS2 Jazzy packages via apt and clean up the source line from shell configs.
|
||||
# Uses the official removal commands to also unregister the ROS2 apt repository.
|
||||
# Удаляем пакеты ROS2 Jazzy через apt и очищаем строку source из конфигов оболочки.
|
||||
# Используем официальные команды удаления, которые также снимают регистрацию apt-репозитория ROS2.
|
||||
def _remove_ros2(write) -> None:
|
||||
"""Remove all ros-jazzy-* packages, the ros2-apt-source package, and the ROS2 source
|
||||
line from .bashrc / .zshrc. Does nothing if /opt/ros/jazzy is not present.
|
||||
Удаляет все пакеты ros-jazzy-*, пакет ros2-apt-source и строку source ROS2 из
|
||||
.bashrc / .zshrc. Ничего не делает если /opt/ros/jazzy отсутствует.
|
||||
def _remove_ros2(log: Log) -> None:
|
||||
"""Remove all ros-jazzy-* packages, the ros2-apt-source, and the ROS2 source line.
|
||||
Удаляет все пакеты ros-jazzy-*, ros2-apt-source и строку source ROS2 из конфигов.
|
||||
"""
|
||||
write("[cyan][*][/cyan] Removing ROS2 Jazzy packages...")
|
||||
log("[cyan]▸[/cyan] Удаление пакетов ROS2 Jazzy...")
|
||||
if not Path("/opt/ros/jazzy").exists():
|
||||
write("[dim]ROS2 Jazzy not found, skipping.[/dim]")
|
||||
log("[dim]ROS2 Jazzy не найден, пропускаем.[/dim]")
|
||||
else:
|
||||
# Remove all ros-jazzy-* packages matched by the apt regex pattern ~n<name>.
|
||||
# Удаляем все пакеты ros-jazzy-* по regex-паттерну apt ~n<имя>.
|
||||
subprocess.run(
|
||||
["sudo", "apt", "remove", "-y", "~nros-jazzy-*"],
|
||||
capture_output=True,
|
||||
)
|
||||
subprocess.run(["sudo", "apt", "autoremove", "-y"], capture_output=True)
|
||||
write("[green][ok][/green] ROS2 Jazzy packages removed")
|
||||
subprocess.run(privilege.sudo(["apt", "remove", "-y", "~nros-jazzy-*"]), capture_output=True)
|
||||
subprocess.run(privilege.sudo(["apt", "autoremove", "-y"]), capture_output=True)
|
||||
log("[green]✓[/green] Пакеты ROS2 Jazzy удалены")
|
||||
subprocess.run(privilege.sudo(["apt", "remove", "-y", "ros2-apt-source"]), capture_output=True)
|
||||
subprocess.run(privilege.sudo(["apt", "update", "-qq"]), capture_output=True)
|
||||
subprocess.run(privilege.sudo(["apt", "autoremove", "-y"]), capture_output=True)
|
||||
log("[green]✓[/green] apt-репозиторий ROS2 удалён")
|
||||
|
||||
# Remove the ROS2 apt source package that added the repository.
|
||||
# Удаляем пакет apt-источника ROS2, который добавил репозиторий.
|
||||
subprocess.run(
|
||||
["sudo", "apt", "remove", "-y", "ros2-apt-source"],
|
||||
capture_output=True,
|
||||
)
|
||||
subprocess.run(["sudo", "apt", "update", "-qq"], capture_output=True)
|
||||
subprocess.run(["sudo", "apt", "autoremove", "-y"], capture_output=True)
|
||||
write("[green][ok][/green] ROS2 apt repository removed")
|
||||
|
||||
# Clean up the source line that local-setup added to the shell config.
|
||||
# Очищаем строку source, добавленную local-setup в конфиг оболочки.
|
||||
source_line = "source /opt/ros/jazzy/setup.bash"
|
||||
for rc_name in [".bashrc", ".zshrc"]:
|
||||
rc = Path.home() / rc_name
|
||||
@@ -116,32 +88,24 @@ def _remove_ros2(write) -> None:
|
||||
content = rc.read_text()
|
||||
if source_line not in content:
|
||||
continue
|
||||
# Remove the whole block that was added by local-setup, not just the single line.
|
||||
# Удаляем весь блок добавленный local-setup, а не только одну строку.
|
||||
new_content = content.replace(f"\n# ROS2 Jazzy\n{source_line}\n", "\n")
|
||||
new_content = new_content.replace(source_line, "")
|
||||
rc.write_text(new_content)
|
||||
write(f"[green][ok][/green] Cleaned up ~/{rc_name}")
|
||||
log(f"[green]✓[/green] Очищен ~/{rc_name}")
|
||||
|
||||
|
||||
# Remove Webots from the system via apt.
|
||||
# Удаляем Webots из системы через apt.
|
||||
def _remove_webots(write) -> None:
|
||||
"""Remove the webots package via apt, run autoremove, and clean up WEBOTS_HOME
|
||||
from .bashrc / .zshrc. Skips if webots is not found on PATH.
|
||||
Удаляет пакет webots через apt, запускает autoremove и очищает WEBOTS_HOME из
|
||||
.bashrc / .zshrc. Пропускает если webots не найден в PATH.
|
||||
def _remove_webots(log: Log) -> None:
|
||||
"""Remove the webots package and clean WEBOTS_HOME from shell configs.
|
||||
Удаляет пакет webots и очищает WEBOTS_HOME из конфигов оболочки.
|
||||
"""
|
||||
write("[cyan][*][/cyan] Removing Webots...")
|
||||
log("[cyan]▸[/cyan] Удаление Webots...")
|
||||
if not shutil.which("webots"):
|
||||
write("[dim]Webots not found, skipping.[/dim]")
|
||||
log("[dim]Webots не найден, пропускаем.[/dim]")
|
||||
return
|
||||
subprocess.run(["sudo", "apt", "remove", "-y", "webots"], capture_output=True)
|
||||
subprocess.run(["sudo", "apt", "autoremove", "-y"], capture_output=True)
|
||||
write("[green][ok][/green] Webots removed")
|
||||
subprocess.run(privilege.sudo(["apt", "remove", "-y", "webots"]), capture_output=True)
|
||||
subprocess.run(privilege.sudo(["apt", "autoremove", "-y"]), capture_output=True)
|
||||
log("[green]✓[/green] Webots удалён")
|
||||
|
||||
# Remove the WEBOTS_HOME block that install_webots.sh added to shell configs.
|
||||
# Удаляем блок WEBOTS_HOME, добавленный install_webots.sh в конфиги оболочки.
|
||||
for rc_name in [".bashrc", ".zshrc"]:
|
||||
rc = Path.home() / rc_name
|
||||
if not rc.exists():
|
||||
@@ -154,154 +118,65 @@ def _remove_webots(write) -> None:
|
||||
new_content = new_content.replace("# Webots\n", "")
|
||||
if new_content != content:
|
||||
rc.write_text(new_content)
|
||||
write(f"[green][ok][/green] Cleaned WEBOTS_HOME from ~/{rc_name}")
|
||||
log(f"[green]✓[/green] Очищен WEBOTS_HOME из ~/{rc_name}")
|
||||
|
||||
|
||||
# Uninstall the cobot CLI from the uv tool store.
|
||||
# Удаляем cobot CLI из хранилища инструментов uv.
|
||||
def _uninstall_cobot(write) -> None:
|
||||
def _uninstall_cobot(log: Log) -> None:
|
||||
"""Uninstall the lightweight-cobot package from the uv tool store.
|
||||
Удаляет пакет lightweight-cobot из хранилища инструментов uv.
|
||||
"""
|
||||
write("[cyan][*][/cyan] Uninstalling cobot CLI...")
|
||||
result = subprocess.run(
|
||||
["uv", "tool", "uninstall", "lightweight-cobot"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
log("[cyan]▸[/cyan] Удаление cobot CLI...")
|
||||
result = subprocess.run(["uv", "tool", "uninstall", "lightweight-cobot"],
|
||||
capture_output=True, text=True)
|
||||
if result.returncode == 0:
|
||||
write("[green][ok][/green] cobot uninstalled")
|
||||
log("[green]✓[/green] cobot удалён")
|
||||
else:
|
||||
write(f"[yellow]Warning:[/yellow] {result.stderr.strip() or 'could not uninstall cobot'}")
|
||||
log(f"[yellow]Предупреждение:[/yellow] {result.stderr.strip() or 'не удалось удалить cobot'}")
|
||||
|
||||
|
||||
# Delete the entire project directory from disk.
|
||||
# Удаляем всю директорию проекта с диска.
|
||||
def _remove_project_dir(write) -> None:
|
||||
"""Recursively delete the entire project directory (_PROJECT_DIR) from disk.
|
||||
Рекурсивно удаляет всю директорию проекта (_PROJECT_DIR) с диска.
|
||||
def _remove_project_dir(log: Log) -> None:
|
||||
"""Recursively delete the entire project directory from disk.
|
||||
Рекурсивно удаляет всю директорию проекта с диска.
|
||||
"""
|
||||
write(f"[cyan][*][/cyan] Removing project directory...")
|
||||
try:
|
||||
log("[cyan]▸[/cyan] Удаление директории проекта...")
|
||||
shutil.rmtree(_PROJECT_DIR)
|
||||
write(f"[green][ok][/green] Removed {_PROJECT_DIR}")
|
||||
except Exception as exc:
|
||||
write(f"[red]Failed:[/red] {exc}")
|
||||
raise
|
||||
log(f"[green]✓[/green] Удалено {_PROJECT_DIR}")
|
||||
|
||||
|
||||
# Run all deletion steps in order.
|
||||
# Progress ranges are split evenly across the active steps so the bar always reaches 100%.
|
||||
# Выполняем все шаги удаления по порядку.
|
||||
# Диапазоны прогресса делятся равномерно между активными шагами, чтобы бар всегда доходил до 100%.
|
||||
def _task_delete(screen: LogScreen, remove_ros: bool, remove_webots: bool) -> None:
|
||||
"""Worker function that runs inside LogScreen. Runs all deletion steps in order:
|
||||
containers -> images -> ROS2 (optional) -> Webots (optional) -> cobot CLI -> project dir.
|
||||
Рабочая функция внутри LogScreen. Выполняет все шаги удаления по порядку:
|
||||
контейнеры -> образы -> ROS2 (опционально) -> Webots (опционально) -> cobot CLI -> директория.
|
||||
def _delete(remove_ros: bool, remove_webots: bool) -> None:
|
||||
"""Run all deletion steps in order, with progress split across the active steps.
|
||||
Выполняет все шаги удаления по порядку, распределяя прогресс между активными шагами.
|
||||
"""
|
||||
ok, fail_msg = True, ""
|
||||
with StepProgress("Удаление проекта") as p:
|
||||
try:
|
||||
screen.set_progress(0, "Stopping containers...")
|
||||
_stop_docker_containers(screen.write)
|
||||
_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...")
|
||||
_remove_docker_images(screen.write)
|
||||
p.set(20, "Удаление Docker-образов...")
|
||||
_remove_docker_images(p.raw)
|
||||
|
||||
pct = 40
|
||||
if remove_ros:
|
||||
screen.set_progress(pct, "Removing ROS2 Jazzy...")
|
||||
_remove_ros2(screen.write)
|
||||
p.set(pct, "Удаление ROS2 Jazzy...")
|
||||
_remove_ros2(p.raw)
|
||||
pct = 65
|
||||
|
||||
if remove_webots:
|
||||
screen.set_progress(pct, "Removing Webots...")
|
||||
_remove_webots(screen.write)
|
||||
p.set(pct, "Удаление Webots...")
|
||||
_remove_webots(p.raw)
|
||||
pct = 75
|
||||
|
||||
screen.set_progress(pct, "Uninstalling cobot CLI...")
|
||||
_uninstall_cobot(screen.write)
|
||||
|
||||
screen.set_progress(88, "Removing project directory...")
|
||||
_remove_project_dir(screen.write)
|
||||
|
||||
if not screen.is_stopped():
|
||||
screen.set_progress(100, "Done")
|
||||
screen.write("\n[green]Project fully removed.[/green]")
|
||||
screen.finish(True)
|
||||
p.set(pct, "Удаление cobot CLI...")
|
||||
_uninstall_cobot(p.raw)
|
||||
|
||||
p.set(88, "Удаление директории проекта...")
|
||||
_remove_project_dir(p.raw)
|
||||
p.set(100, "Готово")
|
||||
except Exception as exc:
|
||||
if not screen.is_stopped():
|
||||
screen.write(f"\n[red]Error:[/red] {exc}")
|
||||
screen.finish(False)
|
||||
ok, fail_msg = False, str(exc)
|
||||
|
||||
|
||||
# Multi-step confirmation wizard before anything is deleted.
|
||||
# Shows extra questions only when the relevant software is actually installed.
|
||||
# Многошаговый мастер подтверждения перед удалением.
|
||||
# Дополнительные вопросы показываются только если соответствующее ПО действительно установлено.
|
||||
class _DeleteApp(App[None]):
|
||||
"""Deletion wizard that asks for confirmation, then optionally asks about ROS2 and Webots,
|
||||
then launches LogScreen running _task_delete.
|
||||
Мастер удаления: просит подтверждение, затем опционально спрашивает про ROS2 и Webots,
|
||||
затем запускает LogScreen с _task_delete.
|
||||
"""
|
||||
CSS = SCREEN_CSS
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.push_screen(
|
||||
PickScreen(
|
||||
"Confirm deletion",
|
||||
"This will permanently delete the project, Docker images and containers. Are you sure?",
|
||||
["No, cancel", "Yes, delete everything"],
|
||||
"No, cancel",
|
||||
),
|
||||
self._on_confirm,
|
||||
)
|
||||
|
||||
def _on_confirm(self, choice: Optional[str]) -> None:
|
||||
if choice is None or choice.startswith("No"):
|
||||
self.exit()
|
||||
return
|
||||
self.push_screen(
|
||||
PickScreen(
|
||||
"ROS2 Jazzy",
|
||||
"Also remove ROS2 Jazzy from the system?",
|
||||
["No, keep ROS2", "Yes, remove ROS2 Jazzy"],
|
||||
"No, keep ROS2",
|
||||
),
|
||||
self._on_ros_choice,
|
||||
)
|
||||
|
||||
def _on_ros_choice(self, choice: Optional[str]) -> None:
|
||||
remove_ros = choice is not None and choice.startswith("Yes")
|
||||
# Only ask about Webots if it is actually installed on this machine.
|
||||
# Спрашиваем про Webots только если он действительно установлен на этой машине.
|
||||
if shutil.which("webots"):
|
||||
self.push_screen(
|
||||
PickScreen(
|
||||
"Webots",
|
||||
"Also remove Webots from the system?",
|
||||
["No, keep Webots", "Yes, remove Webots"],
|
||||
"No, keep Webots",
|
||||
),
|
||||
lambda c: self._on_webots_choice(c, remove_ros),
|
||||
)
|
||||
else:
|
||||
self._start_deletion(remove_ros, remove_webots=False)
|
||||
|
||||
def _on_webots_choice(self, choice: Optional[str], remove_ros: bool) -> None:
|
||||
remove_webots = choice is not None and choice.startswith("Yes")
|
||||
self._start_deletion(remove_ros, remove_webots)
|
||||
|
||||
def _start_deletion(self, remove_ros: bool, remove_webots: bool) -> None:
|
||||
self.push_screen(
|
||||
LogScreen(
|
||||
"Deleting project",
|
||||
lambda s: _task_delete(s, remove_ros, remove_webots),
|
||||
show_progress=True,
|
||||
),
|
||||
lambda _: self.exit(),
|
||||
)
|
||||
done(ok, "Проект полностью удалён" if ok else fail_msg)
|
||||
|
||||
|
||||
def register(subparsers: argparse._SubParsersAction) -> None:
|
||||
@@ -313,4 +188,23 @@ def register(subparsers: argparse._SubParsersAction) -> None:
|
||||
|
||||
|
||||
def run(args: argparse.Namespace) -> None:
|
||||
_DeleteApp().run()
|
||||
ui.header("Удаление проекта", "контейнеры, образы, опционально ROS2/Webots")
|
||||
|
||||
if not ui.confirm(
|
||||
"Это безвозвратно удалит проект, Docker-образы и контейнеры. Продолжить?",
|
||||
default=False,
|
||||
):
|
||||
return
|
||||
|
||||
remove_ros = ui.confirm("Также удалить ROS2 Jazzy из системы?", default=False)
|
||||
|
||||
remove_webots = False
|
||||
if shutil.which("webots"):
|
||||
remove_webots = ui.confirm("Также удалить Webots из системы?", default=False)
|
||||
|
||||
# apt removals need root — acquire sudo once before starting.
|
||||
# Удаление через apt требует root — получаем sudo один раз перед началом.
|
||||
if (remove_ros or remove_webots) and not privilege.ensure_sudo():
|
||||
return
|
||||
|
||||
_delete(remove_ros, remove_webots)
|
||||
|
||||
+115
-239
@@ -7,47 +7,38 @@ import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
from textual.app import App
|
||||
|
||||
from cobot.tui import SCREEN_CSS, InputScreen, LogScreen
|
||||
from cobot import process, ui
|
||||
from cobot.ui import done
|
||||
from cobot.process import StepProgress
|
||||
|
||||
_PROJECT_DIR = Path(__file__).parent.parent.parent
|
||||
|
||||
# The documentation source lives inside the project. We mount it into the container so
|
||||
# MkDocs can pick up live edits without rebuilding the image.
|
||||
# Исходники документации находятся внутри проекта. Монтируем директорию в контейнер, чтобы
|
||||
# MkDocs мог подхватывать изменения вживую без пересборки образа.
|
||||
# The documentation source lives inside the project; it is mounted into the container
|
||||
# so MkDocs picks up live edits without rebuilding the image.
|
||||
# Исходники документации находятся внутри проекта; директория монтируется в контейнер,
|
||||
# чтобы MkDocs подхватывал изменения вживую без пересборки образа.
|
||||
_DOC_DIR = _PROJECT_DIR / "doc" / "lwc-doc"
|
||||
_IMAGE_NAME = "lwc-docs"
|
||||
_CONTAINER_NAME = "lwc-docs"
|
||||
_DEFAULT_PORT = "8000"
|
||||
|
||||
Write = Callable[[str], None]
|
||||
|
||||
|
||||
# Thin wrapper around docker so we do not repeat ["docker", ...] everywhere.
|
||||
# Тонкая обёртка вокруг docker, чтобы не повторять ["docker", ...] везде.
|
||||
def _docker(*args: str, capture: bool = False) -> subprocess.CompletedProcess:
|
||||
"""Run a docker subcommand. Pass capture=True to capture stdout/stderr instead of printing.
|
||||
Запускает подкоманду docker. capture=True перехватывает stdout/stderr вместо вывода на экран.
|
||||
"""Run a docker subcommand.
|
||||
Запускает подкоманду docker.
|
||||
"""
|
||||
return subprocess.run(["docker", *args], capture_output=capture, text=True)
|
||||
|
||||
|
||||
# Check whether the docs container is currently running.
|
||||
# Проверяем, запущен ли сейчас контейнер с документацией.
|
||||
def _is_running() -> bool:
|
||||
"""Return True if the lwc-docs container is currently running.
|
||||
Возвращает True если контейнер lwc-docs в данный момент запущен.
|
||||
Возвращает True если контейнер lwc-docs запущен.
|
||||
"""
|
||||
r = _docker("ps", "--filter", f"name={_CONTAINER_NAME}", "--format", "{{.Names}}", capture=True)
|
||||
return _CONTAINER_NAME in r.stdout
|
||||
|
||||
|
||||
# Check whether the docs Docker image has already been built.
|
||||
# Проверяем, был ли уже собран Docker-образ для документации.
|
||||
def _image_exists() -> bool:
|
||||
"""Return True if the lwc-docs Docker image exists locally.
|
||||
Возвращает True если Docker-образ lwc-docs существует локально.
|
||||
@@ -55,259 +46,132 @@ def _image_exists() -> bool:
|
||||
return bool(_docker("images", "-q", _IMAGE_NAME, capture=True).stdout.strip())
|
||||
|
||||
|
||||
# Build the MkDocs Docker image. Only needs to run once.
|
||||
# Progress comes from parsing "Step X/Y" lines in the docker build output.
|
||||
# Собираем Docker-образ MkDocs. Нужно сделать только один раз.
|
||||
# Прогресс получаем, парся строки "Step X/Y" из вывода docker build.
|
||||
def _build_docs_image(
|
||||
write: Write,
|
||||
on_progress: Optional[Callable[[float], None]] = None,
|
||||
register_proc: Optional[Callable] = None,
|
||||
) -> bool:
|
||||
"""Build the lwc-docs Docker image from the doc/lwc-doc directory. Returns True on success.
|
||||
Собирает Docker-образ lwc-docs из директории doc/lwc-doc. Возвращает True при успехе.
|
||||
def _build_docs_image(p: StepProgress, lo: float, hi: float) -> bool:
|
||||
"""Build the lwc-docs image, mapping "Step X/Y" to the lo..hi progress slice.
|
||||
Собирает образ lwc-docs, отображая "Step X/Y" на участок lo..hi прогресса.
|
||||
"""
|
||||
write("[cyan][*][/cyan] Building documentation image (runs once)...")
|
||||
# DOCKER_BUILDKIT=0 gives us "Step X/Y" lines that we can parse for progress.
|
||||
# DOCKER_BUILDKIT=0 даёт нам строки "Step X/Y", которые можно парсить для прогресса.
|
||||
p.raw("[cyan]▸[/cyan] Сборка образа документации (один раз)...")
|
||||
env = {**os.environ, "DOCKER_BUILDKIT": "0"}
|
||||
proc = subprocess.Popen(
|
||||
["docker", "build", "-t", _IMAGE_NAME, str(_DOC_DIR)],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=env,
|
||||
)
|
||||
if register_proc:
|
||||
register_proc(proc)
|
||||
for line in proc.stdout:
|
||||
s = line.rstrip()
|
||||
|
||||
def on_line(s: str) -> None:
|
||||
if s:
|
||||
write(s)
|
||||
if on_progress:
|
||||
m = re.match(r"Step (\d+)/(\d+) :", line)
|
||||
p.log(s)
|
||||
m = re.match(r"Step (\d+)/(\d+) :", s)
|
||||
if m:
|
||||
step, total = int(m.group(1)), int(m.group(2))
|
||||
on_progress(step / total * 100)
|
||||
proc.wait()
|
||||
if proc.returncode in (-9, -15):
|
||||
p.set(lo + step / total * (hi - lo), f"шаг {step}/{total}")
|
||||
|
||||
rc = process.stream(["docker", "build", "-t", _IMAGE_NAME, str(_DOC_DIR)],
|
||||
env=env, on_line=on_line)
|
||||
if rc != 0:
|
||||
p.raw("[red]Сборка образа не удалась.[/red]")
|
||||
return False
|
||||
if proc.returncode == 0:
|
||||
write("[green][ok][/green] Documentation image ready")
|
||||
p.raw("[green]✓[/green] Образ документации готов")
|
||||
return True
|
||||
write("[red]Image build failed.[/red]")
|
||||
|
||||
|
||||
def _start_container(p: StepProgress, port: str) -> bool:
|
||||
"""Start the MkDocs container on the given port. Returns True on success.
|
||||
Запускает контейнер MkDocs на заданном порту. Возвращает True при успехе.
|
||||
"""
|
||||
p.set(90, "Запуск сервера MkDocs...")
|
||||
p.raw("[cyan]▸[/cyan] Запуск сервера MkDocs...")
|
||||
result = _docker(
|
||||
"run", "-d", "--name", _CONTAINER_NAME, "--rm",
|
||||
"-p", f"{port}:8000",
|
||||
"-v", f"{_DOC_DIR}:/docs",
|
||||
_IMAGE_NAME, "serve", "--dev-addr=0.0.0.0:8000",
|
||||
capture=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
p.raw(f"[red]Не удалось запустить контейнер.[/red]\n{result.stderr}")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# Start the docs server. Builds the image first if it does not exist yet.
|
||||
# Запускаем сервер документации. Сначала собирает образ, если он ещё не существует.
|
||||
def _task_up(screen: LogScreen, port: str) -> None:
|
||||
"""Worker function for the "up" action. Builds the image if missing, then starts the container.
|
||||
Рабочая функция для действия "up". Собирает образ если отсутствует, затем запускает контейнер.
|
||||
def _task_up(port: str) -> None:
|
||||
"""Build the image if missing, then start the docs container.
|
||||
Собирает образ если отсутствует, затем запускает контейнер документации.
|
||||
"""
|
||||
try:
|
||||
if _is_running():
|
||||
screen.write(f"[green]Docs already running at:[/green] http://localhost:{port}")
|
||||
screen.write(" Stop with: [bold]cobot doc-setup down[/bold]")
|
||||
if not screen.is_stopped():
|
||||
screen.finish(True)
|
||||
ui.info(f"[green]Документация уже запущена:[/green] http://localhost:{port}")
|
||||
ui.note("Остановить: cobot doc-setup down")
|
||||
return
|
||||
|
||||
if not _DOC_DIR.exists():
|
||||
screen.write(f"[red]Doc directory not found:[/red] {_DOC_DIR}")
|
||||
if not screen.is_stopped():
|
||||
screen.finish(False)
|
||||
ui.error(f"Директория документации не найдена: {_DOC_DIR}")
|
||||
return
|
||||
|
||||
ok, fail_msg = True, ""
|
||||
with StepProgress("Сервер документации") as p:
|
||||
if not _image_exists():
|
||||
screen.set_progress(0, "Building documentation image...")
|
||||
ok = _build_docs_image(
|
||||
screen.write,
|
||||
on_progress=lambda p: screen.set_progress(p * 0.85, "Building documentation image..."),
|
||||
register_proc=screen.set_proc,
|
||||
)
|
||||
if screen.is_stopped():
|
||||
return
|
||||
if not ok:
|
||||
screen.finish(False)
|
||||
return
|
||||
p.set(0, "Сборка образа документации...")
|
||||
if not _build_docs_image(p, 0, 85):
|
||||
ok, fail_msg = False, "Сборка образа не удалась"
|
||||
else:
|
||||
screen.write("[dim]Documentation image already built, skipping.[/dim]")
|
||||
p.log("Образ документации уже собран, пропускаем.")
|
||||
if ok and not _start_container(p, port):
|
||||
ok, fail_msg = False, "Не удалось запустить контейнер"
|
||||
if ok:
|
||||
p.set(100, "Сервер запущен")
|
||||
|
||||
if screen.is_stopped():
|
||||
return
|
||||
|
||||
screen.set_progress(88, "Starting MkDocs server...")
|
||||
screen.write("\n[cyan][*][/cyan] Starting MkDocs server...")
|
||||
result = _docker(
|
||||
"run", "-d", "--name", _CONTAINER_NAME, "--rm",
|
||||
"-p", f"{port}:8000",
|
||||
# Mount the docs directory so edits appear live without restarting the container.
|
||||
# Монтируем директорию с документацией, чтобы изменения появлялись сразу без перезапуска.
|
||||
"-v", f"{_DOC_DIR}:/docs",
|
||||
_IMAGE_NAME, "serve", "--dev-addr=0.0.0.0:8000",
|
||||
capture=True,
|
||||
)
|
||||
if screen.is_stopped():
|
||||
return
|
||||
if result.returncode != 0:
|
||||
screen.write(f"[red]Failed to start container.[/red]\n{result.stderr}")
|
||||
screen.finish(False)
|
||||
return
|
||||
|
||||
screen.set_progress(100, "Server running")
|
||||
screen.write(f"\n[green]Docs running at:[/green] http://localhost:{port}")
|
||||
screen.write(" Edit files in [bold]doc/lwc-doc/docs/[/bold] — reloads automatically.")
|
||||
screen.write(" Stop with: [bold]cobot doc-setup down[/bold]")
|
||||
if not screen.is_stopped():
|
||||
screen.finish(True)
|
||||
|
||||
except Exception as exc:
|
||||
if not screen.is_stopped():
|
||||
screen.write(f"[red]Error:[/red] {exc}")
|
||||
screen.finish(False)
|
||||
if ok:
|
||||
done(True, f"Документация доступна: http://localhost:{port}")
|
||||
ui.note("Правьте файлы в doc/lwc-doc/docs/ — перезагрузка автоматическая.")
|
||||
ui.note("Остановить: cobot doc-setup down")
|
||||
else:
|
||||
done(False, fail_msg)
|
||||
|
||||
|
||||
# Stop the running docs container.
|
||||
# Останавливаем работающий контейнер с документацией.
|
||||
def _task_down(screen: LogScreen) -> None:
|
||||
"""Worker function for the "down" action. Stops the lwc-docs container if it is running.
|
||||
Рабочая функция для действия "down". Останавливает контейнер lwc-docs если он запущен.
|
||||
def _task_down() -> None:
|
||||
"""Stop the lwc-docs container if it is running.
|
||||
Останавливает контейнер lwc-docs если он запущен.
|
||||
"""
|
||||
try:
|
||||
if not _is_running():
|
||||
screen.write("[yellow]Docs container is not running.[/yellow]")
|
||||
if not screen.is_stopped():
|
||||
screen.finish(True)
|
||||
ui.info("[yellow]Контейнер документации не запущен.[/yellow]")
|
||||
return
|
||||
screen.set_progress(30, "Stopping container...")
|
||||
screen.write("[cyan][*][/cyan] Stopping documentation server...")
|
||||
with StepProgress("Сервер документации") as p:
|
||||
p.set(30, "Остановка контейнера...")
|
||||
p.raw("[cyan]▸[/cyan] Остановка сервера документации...")
|
||||
_docker("stop", _CONTAINER_NAME)
|
||||
if screen.is_stopped():
|
||||
return
|
||||
screen.set_progress(100, "Done")
|
||||
screen.write("[green][ok][/green] Container stopped.")
|
||||
screen.finish(True)
|
||||
except Exception as exc:
|
||||
if not screen.is_stopped():
|
||||
screen.write(f"[red]Error:[/red] {exc}")
|
||||
screen.finish(False)
|
||||
p.set(100, "Готово")
|
||||
done(True, "Контейнер остановлен")
|
||||
|
||||
|
||||
# Stop the container, remove the old image, rebuild it, and start a new container.
|
||||
# Останавливаем контейнер, удаляем старый образ, пересобираем и запускаем новый контейнер.
|
||||
def _task_rebuild(screen: LogScreen, port: str) -> None:
|
||||
"""Worker function for the "rebuild" action. Stops the container, removes the old image,
|
||||
rebuilds it, and starts a fresh container on the given port.
|
||||
Рабочая функция для действия "rebuild". Останавливает контейнер, удаляет старый образ,
|
||||
пересобирает его и запускает новый контейнер на указанном порту.
|
||||
def _task_rebuild(port: str) -> None:
|
||||
"""Stop the container, remove the old image, rebuild it, and start a fresh container.
|
||||
Останавливает контейнер, удаляет старый образ, пересобирает и запускает новый контейнер.
|
||||
"""
|
||||
try:
|
||||
ok, fail_msg = True, ""
|
||||
with StepProgress("Сервер документации — пересборка") as p:
|
||||
if _is_running():
|
||||
screen.set_progress(5, "Stopping container...")
|
||||
screen.write("[cyan][*][/cyan] Stopping existing container...")
|
||||
p.set(5, "Остановка контейнера...")
|
||||
_docker("stop", _CONTAINER_NAME)
|
||||
if screen.is_stopped():
|
||||
return
|
||||
screen.write("[green][ok][/green] Stopped.")
|
||||
|
||||
p.raw("[green]✓[/green] Остановлен.")
|
||||
if _image_exists():
|
||||
screen.set_progress(15, "Removing old image...")
|
||||
screen.write("[cyan][*][/cyan] Removing old image...")
|
||||
p.set(15, "Удаление старого образа...")
|
||||
_docker("rmi", "-f", _IMAGE_NAME)
|
||||
if screen.is_stopped():
|
||||
return
|
||||
screen.write("[green][ok][/green] Image removed.")
|
||||
p.raw("[green]✓[/green] Образ удалён.")
|
||||
p.set(20, "Сборка образа документации...")
|
||||
if not _build_docs_image(p, 20, 88):
|
||||
ok, fail_msg = False, "Сборка образа не удалась"
|
||||
if ok and not _start_container(p, port):
|
||||
ok, fail_msg = False, "Не удалось запустить контейнер"
|
||||
if ok:
|
||||
p.set(100, "Сервер запущен")
|
||||
|
||||
screen.set_progress(20, "Building documentation image...")
|
||||
ok = _build_docs_image(
|
||||
screen.write,
|
||||
on_progress=lambda p: screen.set_progress(20 + p * 0.68, "Building documentation image..."),
|
||||
register_proc=screen.set_proc,
|
||||
)
|
||||
if screen.is_stopped():
|
||||
return
|
||||
if not ok:
|
||||
screen.finish(False)
|
||||
return
|
||||
|
||||
screen.set_progress(90, "Starting MkDocs server...")
|
||||
screen.write("\n[cyan][*][/cyan] Starting MkDocs server...")
|
||||
result = _docker(
|
||||
"run", "-d", "--name", _CONTAINER_NAME, "--rm",
|
||||
"-p", f"{port}:8000",
|
||||
"-v", f"{_DOC_DIR}:/docs",
|
||||
_IMAGE_NAME, "serve", "--dev-addr=0.0.0.0:8000",
|
||||
capture=True,
|
||||
)
|
||||
if screen.is_stopped():
|
||||
return
|
||||
if result.returncode != 0:
|
||||
screen.write(f"[red]Failed to start container.[/red]\n{result.stderr}")
|
||||
screen.finish(False)
|
||||
return
|
||||
|
||||
screen.set_progress(100, "Server running")
|
||||
screen.write(f"\n[green]Docs running at:[/green] http://localhost:{port}")
|
||||
screen.write(" Stop with: [bold]cobot doc-setup down[/bold]")
|
||||
if not screen.is_stopped():
|
||||
screen.finish(True)
|
||||
|
||||
except Exception as exc:
|
||||
if not screen.is_stopped():
|
||||
screen.write(f"[red]Error:[/red] {exc}")
|
||||
screen.finish(False)
|
||||
|
||||
|
||||
# One app handles all three actions (up/down/rebuild) by branching in on_mount.
|
||||
# Одно приложение обрабатывает все три действия (up/down/rebuild), разветвляясь в on_mount.
|
||||
class _DocApp(App[None]):
|
||||
"""Documentation server app. Handles "up", "down", and "rebuild" actions by branching
|
||||
in on_mount to the appropriate LogScreen task.
|
||||
Приложение сервера документации. Обрабатывает действия "up", "down" и "rebuild",
|
||||
разветвляясь в on_mount к соответствующей задаче LogScreen.
|
||||
"""
|
||||
CSS = SCREEN_CSS
|
||||
|
||||
def __init__(self, action: str):
|
||||
super().__init__()
|
||||
self._action = action
|
||||
|
||||
def on_mount(self) -> None:
|
||||
if self._action == "down":
|
||||
self.push_screen(
|
||||
LogScreen("Documentation server", _task_down, show_progress=True),
|
||||
lambda _: self.exit(),
|
||||
)
|
||||
elif self._action == "rebuild":
|
||||
self.push_screen(
|
||||
InputScreen("Step 1 of 1", "Port to serve documentation on:", _DEFAULT_PORT),
|
||||
self._got_port_rebuild,
|
||||
)
|
||||
if ok:
|
||||
done(True, f"Документация доступна: http://localhost:{port}")
|
||||
ui.note("Остановить: cobot doc-setup down")
|
||||
else:
|
||||
self.push_screen(
|
||||
InputScreen("Step 1 of 1", "Port to serve documentation on:", _DEFAULT_PORT),
|
||||
self._got_port_up,
|
||||
)
|
||||
done(False, fail_msg)
|
||||
|
||||
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 _normalize_port(value: str) -> str:
|
||||
"""Return a numeric port string, falling back to the default when invalid.
|
||||
Возвращает числовой порт, откатываясь на значение по умолчанию при ошибке.
|
||||
"""
|
||||
value = (value or "").strip()
|
||||
return value if value.isdigit() else _DEFAULT_PORT
|
||||
|
||||
|
||||
def register(subparsers: argparse._SubParsersAction) -> None:
|
||||
@@ -324,9 +188,21 @@ def register(subparsers: argparse._SubParsersAction) -> None:
|
||||
|
||||
def run(args: argparse.Namespace) -> None:
|
||||
if not shutil.which("docker"):
|
||||
from rich.console import Console
|
||||
Console().print("[red]Error:[/red] Docker is not installed or not on PATH.")
|
||||
ui.error("Docker не установлен или отсутствует в PATH.")
|
||||
sys.exit(1)
|
||||
|
||||
action = getattr(args, "action", "up")
|
||||
_DocApp(action).run()
|
||||
|
||||
if action == "down":
|
||||
_task_down()
|
||||
return
|
||||
|
||||
port_v = ui.text("Порт для сервера документации:", _DEFAULT_PORT)
|
||||
if port_v is None:
|
||||
return
|
||||
port = _normalize_port(port_v)
|
||||
|
||||
if action == "rebuild":
|
||||
_task_rebuild(port)
|
||||
else:
|
||||
_task_up(port)
|
||||
|
||||
+117
-301
@@ -4,15 +4,14 @@ import argparse
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable, List, Optional
|
||||
from typing import List, Optional
|
||||
|
||||
from textual.app import App
|
||||
|
||||
from cobot.tui import SCREEN_CSS, InputScreen, LogScreen, PickScreen
|
||||
from cobot import process, ui
|
||||
from cobot.ui import done
|
||||
from cobot.process import StepProgress
|
||||
|
||||
_PROJECT_DIR = Path(__file__).parent.parent.parent
|
||||
_DOCKER_DIR = _PROJECT_DIR / "docker"
|
||||
@@ -27,8 +26,8 @@ _DEFAULT_PREFIX = "lwc-local"
|
||||
_CONTROLLER_CHAIN = ["ros-core", "ros-base", "ros-iiwa7"]
|
||||
_WEBOTS_CHAIN = ["ros-core", "ros-base", "ros-iiwa7-webots"]
|
||||
|
||||
# Maps each image to the image it is built FROM. None means it starts from scratch (base Ubuntu).
|
||||
# Сопоставляет каждый образ с тем, на основе которого он собирается. None - начинает с нуля (базовый Ubuntu).
|
||||
# Maps each image to the image it is built FROM. None means it starts from base Ubuntu.
|
||||
# Сопоставляет каждый образ с тем, на основе которого он собирается. None - базовый Ubuntu.
|
||||
_IMAGE_PARENT: dict[str, str | None] = {
|
||||
"ros-core": None,
|
||||
"ros-base": "ros-core",
|
||||
@@ -36,15 +35,11 @@ _IMAGE_PARENT: dict[str, str | None] = {
|
||||
"ros-iiwa7-webots": "ros-base",
|
||||
}
|
||||
|
||||
# These images need the full project source as Docker build context because they copy source files.
|
||||
# Эти образы требуют полный исходный код проекта как контекст сборки, потому что копируют файлы.
|
||||
# These images need the full project source as Docker build context.
|
||||
# Эти образы требуют полный исходный код проекта как контекст сборки.
|
||||
_NEEDS_PROJECT_CTX = {"ros-iiwa7", "ros-iiwa7-webots"}
|
||||
|
||||
Write = Callable[[str], None]
|
||||
|
||||
|
||||
# All the choices the user makes in the wizard are stored here before we start the actual build.
|
||||
# Все выборы пользователя в мастере хранятся здесь перед началом фактической сборки.
|
||||
@dataclass
|
||||
class _Config:
|
||||
ros_version: str
|
||||
@@ -55,29 +50,14 @@ class _Config:
|
||||
hub_repo: str
|
||||
|
||||
|
||||
# Build one Docker image and stream its output to the log.
|
||||
# Progress is tracked by parsing "Step X/Y" lines that Docker prints during the build.
|
||||
# Собирает один Docker-образ и транслирует его вывод в лог.
|
||||
# Прогресс отслеживается по строкам "Step X/Y", которые Docker печатает во время сборки.
|
||||
def _build_image(
|
||||
name: str,
|
||||
tag: str,
|
||||
dockerfile: Path,
|
||||
ctx: Path,
|
||||
write: Write,
|
||||
on_progress: Optional[Callable[[float], None]] = None,
|
||||
parent_tag: Optional[str] = None,
|
||||
build_type: str = "release",
|
||||
register_proc: Optional[Callable] = None,
|
||||
) -> bool:
|
||||
"""Build a single Docker image from a Dockerfile and stream its output line by line.
|
||||
Returns True on success, False if the build failed or was cancelled.
|
||||
Собирает один Docker-образ из Dockerfile и транслирует вывод построчно.
|
||||
Возвращает True при успехе, False если сборка завершилась ошибкой или была отменена.
|
||||
def _build_image(name: str, tag: str, dockerfile: Path, ctx: Path, p: StepProgress,
|
||||
lo: float, hi: float, parent_tag: Optional[str], build_type: str) -> bool:
|
||||
"""Build a single Docker image, streaming output and mapping "Step X/Y" to the
|
||||
lo..hi slice of the progress bar. Returns True on success.
|
||||
Собирает один Docker-образ, транслируя вывод и отображая "Step X/Y" на участок
|
||||
lo..hi прогресс-бара. Возвращает True при успехе.
|
||||
"""
|
||||
write(f"[cyan][*][/cyan] Building [bold]{name}[/bold]...")
|
||||
# DOCKER_BUILDKIT=0 gives us "Step X/Y" lines in the output which we parse for progress.
|
||||
# DOCKER_BUILDKIT=0 даёт нам строки "Step X/Y" в выводе, которые мы парсим для прогресса.
|
||||
p.raw(f"[cyan]▸[/cyan] Сборка [bold]{name}[/bold]...")
|
||||
env = {**os.environ, "DOCKER_BUILDKIT": "0"}
|
||||
cmd = [
|
||||
"docker", "build", "-t", tag, "-f", str(dockerfile),
|
||||
@@ -87,315 +67,109 @@ def _build_image(
|
||||
cmd += ["--build-arg", f"IMAGE={parent_tag}"]
|
||||
cmd.append(str(ctx))
|
||||
|
||||
proc = subprocess.Popen(
|
||||
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=env,
|
||||
)
|
||||
if register_proc:
|
||||
register_proc(proc)
|
||||
for line in proc.stdout:
|
||||
s = line.rstrip()
|
||||
def on_line(s: str) -> None:
|
||||
if s:
|
||||
write(s)
|
||||
if on_progress:
|
||||
m = re.match(r"Step (\d+)/(\d+) :", line)
|
||||
p.log(s)
|
||||
m = re.match(r"Step (\d+)/(\d+) :", s)
|
||||
if m:
|
||||
step, total = int(m.group(1)), int(m.group(2))
|
||||
on_progress(step / total * 100)
|
||||
proc.wait()
|
||||
if proc.returncode in (-9, -15):
|
||||
p.set(lo + step / total * (hi - lo), f"{name}: шаг {step}/{total}")
|
||||
|
||||
rc = process.stream(cmd, env=env, on_line=on_line)
|
||||
if rc in (-9, -15):
|
||||
return False
|
||||
if proc.returncode == 0:
|
||||
write(f"[green][ok][/green] {name}")
|
||||
if rc == 0:
|
||||
p.raw(f"[green]✓[/green] {name}")
|
||||
return True
|
||||
write(f"[red]Build failed:[/red] {tag}")
|
||||
p.raw(f"[red]Сборка не удалась:[/red] {tag}")
|
||||
return False
|
||||
|
||||
|
||||
# Pull a Docker image from Hub and track progress by counting downloaded layers.
|
||||
# Скачиваем Docker-образ с Hub и отслеживаем прогресс по количеством скачанных слоёв.
|
||||
def _pull_image(
|
||||
name: str,
|
||||
tag: str,
|
||||
write: Write,
|
||||
on_progress: Optional[Callable[[float], None]] = None,
|
||||
register_proc: Optional[Callable] = None,
|
||||
) -> bool:
|
||||
"""Pull a Docker image from Docker Hub and report layer-by-layer progress.
|
||||
Returns True on success, False if the pull failed or was cancelled.
|
||||
Скачивает Docker-образ с Docker Hub и сообщает о прогрессе по слоям.
|
||||
Возвращает True при успехе, False если скачивание завершилось ошибкой или было отменено.
|
||||
def _pull_image(name: str, tag: str, p: StepProgress, lo: float, hi: float) -> bool:
|
||||
"""Pull a Docker image, tracking progress by counting completed layers.
|
||||
Скачивает Docker-образ, отслеживая прогресс по числу завершённых слоёв.
|
||||
"""
|
||||
write(f"[cyan][*][/cyan] Pulling [bold]{name}[/bold] ({tag})...")
|
||||
if on_progress:
|
||||
on_progress(5)
|
||||
|
||||
proc = subprocess.Popen(
|
||||
["docker", "pull", tag],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||||
)
|
||||
if register_proc:
|
||||
register_proc(proc)
|
||||
p.raw(f"[cyan]▸[/cyan] Скачивание [bold]{name}[/bold] ({tag})...")
|
||||
layers_total = 0
|
||||
layers_done = 0
|
||||
for line in proc.stdout:
|
||||
s = line.rstrip()
|
||||
|
||||
def on_line(s: str) -> None:
|
||||
nonlocal layers_total, layers_done
|
||||
if s:
|
||||
write(s)
|
||||
# Count layers as they appear and mark them done when Docker confirms they are pulled.
|
||||
# Считаем слои по мере их появления и отмечаем завершёнными когда Docker подтверждает скачивание.
|
||||
if "Pulling fs layer" in line or "Waiting" in line:
|
||||
p.log(s)
|
||||
if "Pulling fs layer" in s or "Waiting" in s:
|
||||
layers_total += 1
|
||||
elif "Pull complete" in line or "Already exists" in line:
|
||||
elif "Pull complete" in s or "Already exists" in s:
|
||||
layers_done += 1
|
||||
if on_progress and layers_total > 0:
|
||||
on_progress(5 + layers_done / layers_total * 90)
|
||||
proc.wait()
|
||||
if proc.returncode in (-9, -15):
|
||||
if layers_total > 0:
|
||||
p.set(lo + layers_done / layers_total * (hi - lo), f"{name}: слои")
|
||||
|
||||
rc = process.stream(["docker", "pull", tag], on_line=on_line)
|
||||
if rc in (-9, -15):
|
||||
return False
|
||||
if proc.returncode == 0:
|
||||
write(f"[green][ok][/green] {name}")
|
||||
if on_progress:
|
||||
on_progress(100)
|
||||
if rc == 0:
|
||||
p.raw(f"[green]✓[/green] {name}")
|
||||
return True
|
||||
write(f"[red]Pull failed:[/red] {tag}")
|
||||
p.raw(f"[red]Скачивание не удалось:[/red] {tag}")
|
||||
return False
|
||||
|
||||
|
||||
# The actual work - either build or pull all images depending on what the user chose.
|
||||
# Each image gets its own slice of the progress bar so the overall bar advances smoothly.
|
||||
# Основная работа - собираем или скачиваем все образы в зависимости от выбора пользователя.
|
||||
# Каждый образ получает свой кусок прогресс-бара, чтобы общий бар двигался равномерно.
|
||||
def _task_execute(screen: LogScreen, cfg: _Config) -> None:
|
||||
"""Worker function that runs inside LogScreen. Builds or pulls all images in the chain
|
||||
defined by the user's choices and updates the progress bar after each image.
|
||||
Рабочая функция, выполняемая внутри LogScreen. Собирает или скачивает все образы из цепочки
|
||||
согласно выбору пользователя и обновляет прогресс-бар после каждого образа.
|
||||
def _execute(cfg: _Config) -> None:
|
||||
"""Build or pull all images in the chain selected by the user's choices.
|
||||
Собирает или скачивает все образы из цепочки, выбранной пользователем.
|
||||
"""
|
||||
try:
|
||||
chain = _WEBOTS_CHAIN if cfg.variant == "webots" else _CONTROLLER_CHAIN
|
||||
n = len(chain)
|
||||
ok = True
|
||||
fail_msg = ""
|
||||
|
||||
if cfg.source == "build":
|
||||
screen.write(
|
||||
f"[bold]Building {n} image(s) — "
|
||||
f"ROS {cfg.ros_version} — {cfg.build_type}[/bold]\n"
|
||||
)
|
||||
title = f"Сборка {n} образ(ов) — ROS {cfg.ros_version} — {cfg.build_type}"
|
||||
with StepProgress(title) as p:
|
||||
for i, name in enumerate(chain):
|
||||
if screen.is_stopped():
|
||||
return
|
||||
lo = i / n * 100
|
||||
hi = (i + 1) / n * 100
|
||||
screen.set_progress(lo, f"Image {i + 1}/{n}: building {name}...")
|
||||
|
||||
lo, hi = i / n * 100, (i + 1) / n * 100
|
||||
p.set(lo, f"Образ {i + 1}/{n}: {name}")
|
||||
tag = f"{cfg.image_prefix}:{name}-{cfg.ros_version}"
|
||||
dockerfile = _DOCKER_DIR / cfg.ros_version / name / "Dockerfile"
|
||||
if not dockerfile.exists():
|
||||
screen.write(f"[red]Dockerfile not found:[/red] {dockerfile}")
|
||||
if not screen.is_stopped():
|
||||
screen.finish(False)
|
||||
return
|
||||
ok, fail_msg = False, f"Dockerfile не найден: {dockerfile}"
|
||||
break
|
||||
ctx = _PROJECT_DIR if name in _NEEDS_PROJECT_CTX else dockerfile.parent
|
||||
parent_name = _IMAGE_PARENT.get(name)
|
||||
parent_tag = (
|
||||
f"{cfg.image_prefix}:{parent_name}-{cfg.ros_version}"
|
||||
if parent_name else None
|
||||
)
|
||||
ok = _build_image(
|
||||
name, tag, dockerfile, ctx, screen.write,
|
||||
on_progress=lambda p, lo=lo, hi=hi: screen.set_progress(
|
||||
lo + p * (hi - lo) / 100, f"Image {i + 1}/{n}: building {name}..."
|
||||
),
|
||||
parent_tag=parent_tag,
|
||||
build_type=cfg.build_type,
|
||||
register_proc=screen.set_proc,
|
||||
)
|
||||
if screen.is_stopped():
|
||||
return
|
||||
if not ok:
|
||||
screen.finish(False)
|
||||
return
|
||||
screen.set_progress(hi)
|
||||
|
||||
if not screen.is_stopped():
|
||||
screen.set_progress(100, "All images built")
|
||||
screen.write(
|
||||
f"\n[green]Done.[/green] "
|
||||
f"Images tagged [bold]{cfg.image_prefix}:<name>-{cfg.ros_version}[/bold]."
|
||||
)
|
||||
|
||||
parent_tag = (f"{cfg.image_prefix}:{parent_name}-{cfg.ros_version}"
|
||||
if parent_name else None)
|
||||
if not _build_image(name, tag, dockerfile, ctx, p, lo, hi,
|
||||
parent_tag, cfg.build_type):
|
||||
ok, fail_msg = False, f"Сборка образа {name} не удалась"
|
||||
break
|
||||
if ok:
|
||||
p.set(100, "Готово")
|
||||
done(ok, f"Образы готовы: {cfg.image_prefix}:<name>-{cfg.ros_version}"
|
||||
if ok else fail_msg)
|
||||
else:
|
||||
short = "webots" if cfg.variant == "webots" else "iiwa"
|
||||
suffix = "-dev" if cfg.build_type == "dev" else ""
|
||||
full_ref = f"{cfg.hub_repo}:{short}-{cfg.ros_version}{suffix}"
|
||||
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)
|
||||
with StepProgress(f"Скачивание из {cfg.hub_repo} — ROS {cfg.ros_version}") as p:
|
||||
if _pull_image(short, full_ref, p, 0, 100):
|
||||
p.set(100, "Готово")
|
||||
else:
|
||||
ok, fail_msg = False, f"Скачивание {full_ref} не удалось"
|
||||
done(ok, f"Образ готов: {full_ref}" if ok else fail_msg)
|
||||
|
||||
|
||||
# Scan the docker/ directory for subdirectories named after ROS versions (e.g. jazzy).
|
||||
# If nothing is found we fall back to "jazzy" so the wizard still works.
|
||||
# Сканируем директорию docker/ на наличие поддиректорий с именами версий ROS (например jazzy).
|
||||
# Если ничего не найдено, используем "jazzy" по умолчанию, чтобы мастер всё равно работал.
|
||||
def _discover_versions() -> List[str]:
|
||||
"""Return a sorted list of ROS versions found in docker/. Jazzy is placed first.
|
||||
Falls back to ["jazzy"] if the directory does not exist or is empty.
|
||||
Возвращает отсортированный список версий ROS найденных в docker/. Jazzy идёт первым.
|
||||
Возвращает ["jazzy"] если директория не существует или пуста.
|
||||
"""Return ROS versions found in docker/ (jazzy first). Falls back to ["jazzy"].
|
||||
Возвращает версии ROS из docker/ (jazzy первым). По умолчанию ["jazzy"].
|
||||
"""
|
||||
if not _DOCKER_DIR.exists():
|
||||
return ["jazzy"]
|
||||
dirs = sorted(d.name for d in _DOCKER_DIR.iterdir() if d.is_dir())
|
||||
# Put jazzy first so it is the pre-selected default in the wizard.
|
||||
# Ставим jazzy первым, чтобы он был предвыбранным по умолчанию в мастере.
|
||||
if "jazzy" in dirs:
|
||||
dirs = ["jazzy"] + [d for d in dirs if d != "jazzy"]
|
||||
return dirs or ["jazzy"]
|
||||
|
||||
|
||||
# Multi-step wizard that collects all build options before starting the actual image build.
|
||||
# Многошаговый мастер, который собирает все параметры сборки перед запуском фактической сборки образа.
|
||||
class _Wizard(App[None]):
|
||||
"""Five-step wizard: ROS version -> source (pull/build) -> variant -> build type -> repo/prefix.
|
||||
Collects all options, then hands off to LogScreen which runs _task_execute.
|
||||
Пятишаговый мастер: версия ROS -> источник (pull/build) -> вариант -> тип сборки -> репо/префикс.
|
||||
Собирает все параметры, затем передаёт управление LogScreen который запускает _task_execute.
|
||||
"""
|
||||
CSS = SCREEN_CSS
|
||||
|
||||
def __init__(self, versions: List[str], default_version: str = "jazzy"):
|
||||
super().__init__()
|
||||
self.versions = versions
|
||||
self.default_version = default_version
|
||||
self._state: dict = {}
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self._ask_version()
|
||||
|
||||
def _ask_version(self) -> None:
|
||||
self.push_screen(
|
||||
PickScreen("Step 1 of 5", "Select ROS version:", self.versions, self.default_version),
|
||||
self._got_version,
|
||||
)
|
||||
|
||||
def _got_version(self, v: Optional[str]) -> None:
|
||||
if v is None:
|
||||
self.exit()
|
||||
return
|
||||
self._state["ros_version"] = v
|
||||
self.push_screen(
|
||||
PickScreen(
|
||||
"Step 2 of 5", "Source:",
|
||||
["Pull from Docker Hub", "Build locally"],
|
||||
"Pull from Docker Hub",
|
||||
),
|
||||
self._got_source,
|
||||
)
|
||||
|
||||
def _got_source(self, v: Optional[str]) -> None:
|
||||
if v is None:
|
||||
self.exit()
|
||||
return
|
||||
self._state["source"] = "build" if v == "Build locally" else "pull"
|
||||
self.push_screen(
|
||||
PickScreen(
|
||||
"Step 3 of 5",
|
||||
"What to install:",
|
||||
[
|
||||
"Controller only — ros-core, ros-base, ros-iiwa7",
|
||||
"Controller with Webots — ros-core, ros-base, ros-iiwa7-webots",
|
||||
],
|
||||
"Controller only — ros-core, ros-base, ros-iiwa7",
|
||||
),
|
||||
self._got_variant,
|
||||
)
|
||||
|
||||
def _got_variant(self, v: Optional[str]) -> None:
|
||||
if v is None:
|
||||
self.exit()
|
||||
return
|
||||
self._state["variant"] = "webots" if v.startswith("Controller with Webots") else "controller"
|
||||
self.push_screen(
|
||||
PickScreen("Step 4 of 5", "Build type:", ["release", "dev"], "release"),
|
||||
self._got_build_type,
|
||||
)
|
||||
|
||||
def _got_build_type(self, v: Optional[str]) -> None:
|
||||
if v is None:
|
||||
self.exit()
|
||||
return
|
||||
self._state["build_type"] = v or "release"
|
||||
# Pull needs a Hub repo name, build needs a local image prefix.
|
||||
# Для pull нужно имя репозитория на Hub, для build - локальный префикс образов.
|
||||
if self._state["source"] == "pull":
|
||||
self.push_screen(
|
||||
InputScreen("Step 5 of 5", "Docker Hub repository:", _DEFAULT_HUB_REPO),
|
||||
self._got_hub_repo,
|
||||
)
|
||||
else:
|
||||
self.push_screen(
|
||||
InputScreen("Step 5 of 5", "Image prefix:", _DEFAULT_PREFIX),
|
||||
self._got_prefix,
|
||||
)
|
||||
|
||||
def _got_hub_repo(self, v: Optional[str]) -> None:
|
||||
if v is None:
|
||||
self.exit()
|
||||
return
|
||||
self._state["hub_repo"] = v
|
||||
self._finish()
|
||||
|
||||
def _got_prefix(self, v: Optional[str]) -> None:
|
||||
if v is None:
|
||||
self.exit()
|
||||
return
|
||||
self._state["image_prefix"] = v
|
||||
self._finish()
|
||||
|
||||
def _finish(self) -> None:
|
||||
# Assemble the config and hand it off to the log screen that does the actual work.
|
||||
# Собираем конфиг и передаём его экрану лога, который выполняет фактическую работу.
|
||||
s = self._state
|
||||
cfg = _Config(
|
||||
ros_version=s["ros_version"],
|
||||
variant=s["variant"],
|
||||
source=s["source"],
|
||||
build_type=s["build_type"],
|
||||
image_prefix=s.get("image_prefix", _DEFAULT_PREFIX),
|
||||
hub_repo=s.get("hub_repo", _DEFAULT_HUB_REPO),
|
||||
)
|
||||
title = (
|
||||
f"Building Docker images — ROS {cfg.ros_version}"
|
||||
if cfg.source == "build"
|
||||
else f"Pulling Docker image — ROS {cfg.ros_version}"
|
||||
)
|
||||
self.push_screen(
|
||||
LogScreen(title, lambda screen: _task_execute(screen, cfg), show_progress=True),
|
||||
lambda _: self.exit(),
|
||||
)
|
||||
|
||||
|
||||
def register(subparsers: argparse._SubParsersAction) -> None:
|
||||
p = subparsers.add_parser("docker-setup", help="Build or pull Docker images for KUKA iiwa7")
|
||||
p.set_defaults(func=run)
|
||||
@@ -403,10 +177,52 @@ def register(subparsers: argparse._SubParsersAction) -> None:
|
||||
|
||||
def run(args: argparse.Namespace) -> None:
|
||||
if not shutil.which("docker"):
|
||||
from rich.console import Console
|
||||
Console().print("[red]Error:[/red] Docker is not installed or not on PATH.")
|
||||
ui.error("Docker не установлен или отсутствует в PATH.")
|
||||
sys.exit(1)
|
||||
|
||||
ui.header("Настройка Docker", "сборка или скачивание образов KUKA iiwa7")
|
||||
|
||||
versions = _discover_versions()
|
||||
default = "jazzy" if "jazzy" in versions else versions[0]
|
||||
_Wizard(versions=versions, default_version=default).run()
|
||||
|
||||
ros_version = ui.select("Версия ROS:", versions, default)
|
||||
if ros_version is None:
|
||||
return
|
||||
|
||||
src = ui.select("Источник:", ["Скачать с Docker Hub", "Собрать локально"],
|
||||
"Скачать с Docker Hub")
|
||||
if src is None:
|
||||
return
|
||||
source = "build" if src == "Собрать локально" else "pull"
|
||||
|
||||
variant_v = ui.select(
|
||||
"Что установить:",
|
||||
["Только контроллер — ros-core, ros-base, ros-iiwa7",
|
||||
"Контроллер с Webots — ros-core, ros-base, ros-iiwa7-webots"],
|
||||
"Только контроллер — ros-core, ros-base, ros-iiwa7",
|
||||
)
|
||||
if variant_v is None:
|
||||
return
|
||||
variant = "webots" if variant_v.startswith("Контроллер с Webots") else "controller"
|
||||
|
||||
build_type = ui.select("Тип сборки:", ["release", "dev"], "release")
|
||||
if build_type is None:
|
||||
return
|
||||
|
||||
image_prefix, hub_repo = _DEFAULT_PREFIX, _DEFAULT_HUB_REPO
|
||||
if source == "pull":
|
||||
v = ui.text("Репозиторий Docker Hub:", _DEFAULT_HUB_REPO)
|
||||
if v is None:
|
||||
return
|
||||
hub_repo = v
|
||||
else:
|
||||
v = ui.text("Префикс образов:", _DEFAULT_PREFIX)
|
||||
if v is None:
|
||||
return
|
||||
image_prefix = v
|
||||
|
||||
cfg = _Config(
|
||||
ros_version=ros_version, variant=variant, source=source,
|
||||
build_type=build_type, image_prefix=image_prefix, hub_repo=hub_repo,
|
||||
)
|
||||
_execute(cfg)
|
||||
|
||||
+225
-374
@@ -5,11 +5,10 @@ import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Callable, List, Optional
|
||||
|
||||
from textual.app import App
|
||||
|
||||
from cobot.tui import SCREEN_CSS, LogScreen, PickScreen
|
||||
from cobot import process, ui
|
||||
from cobot import privilege
|
||||
from cobot.ui import done, header
|
||||
from cobot.commands.docker_setup import run as _docker_setup
|
||||
|
||||
# Root directory of the project, used as the working directory for colcon builds.
|
||||
@@ -28,9 +27,12 @@ _WEBOTS_VERSION = "2025a"
|
||||
# Директория с shell-скриптами, используемыми этой командой.
|
||||
_SCRIPTS_DIR = _PROJECT_DIR / "scripts"
|
||||
|
||||
# Type alias for the callable used to write a line to the TUI log screen.
|
||||
# Псевдоним типа для функции записи строки в лог TUI.
|
||||
Write = Callable[[str], None]
|
||||
# apt packages that must exist before rosdep can install the pip-based keys
|
||||
# (python3-pip / dev / venv). Their absence is what produced the "pip is not
|
||||
# installed" failure in the screenshots.
|
||||
# apt-пакеты, необходимые до того как rosdep сможет установить pip-зависимости.
|
||||
# Именно их отсутствие давало ошибку "pip is not installed" на скриншотах.
|
||||
_APT_PREREQS = ["python3-pip", "python3-dev", "python3-venv"]
|
||||
|
||||
|
||||
# OS and tool detection helpers
|
||||
@@ -38,9 +40,7 @@ Write = Callable[[str], None]
|
||||
def _detect_ubuntu_2404() -> bool:
|
||||
"""Return True if the current OS is Ubuntu 24.04 (Noble).
|
||||
|
||||
Reads /etc/os-release and checks the ID and VERSION_ID fields.
|
||||
Возвращает True, если текущая ОС - Ubuntu 24.04 (Noble).
|
||||
Читает /etc/os-release и проверяет поля ID и VERSION_ID.
|
||||
"""
|
||||
path = Path("/etc/os-release")
|
||||
if not path.exists():
|
||||
@@ -55,7 +55,6 @@ def _detect_ubuntu_2404() -> bool:
|
||||
|
||||
def _detect_ros2() -> bool:
|
||||
"""Return True if ROS2 Jazzy is already installed under /opt/ros/jazzy.
|
||||
|
||||
Возвращает True, если ROS2 Jazzy уже установлен в /opt/ros/jazzy.
|
||||
"""
|
||||
return Path(f"/opt/ros/{_DISTRO}").is_dir()
|
||||
@@ -63,7 +62,6 @@ def _detect_ros2() -> bool:
|
||||
|
||||
def webots_installed() -> bool:
|
||||
"""Return True if the Webots binary is available on PATH.
|
||||
|
||||
Возвращает True, если бинарный файл Webots доступен в PATH.
|
||||
"""
|
||||
return shutil.which("webots") is not None
|
||||
@@ -77,8 +75,6 @@ def _ros2_env() -> dict:
|
||||
os.environ if the setup file does not exist yet.
|
||||
|
||||
Формирует словарь окружения с переменными ROS2, полученными из setup.bash.
|
||||
Запускает /opt/ros/jazzy/setup.bash в подпроцессе, перехватывает все
|
||||
экспортированные переменные и объединяет их с копией os.environ.
|
||||
Возвращает чистый os.environ если файл setup.bash ещё не существует.
|
||||
"""
|
||||
setup = Path(f"/opt/ros/{_DISTRO}/setup.bash")
|
||||
@@ -96,15 +92,13 @@ def _ros2_env() -> dict:
|
||||
|
||||
# CMake's find_package(Python3) ignores PATH and uses its own search logic,
|
||||
# so we must pin it explicitly to the system Python where catkin_pkg is installed.
|
||||
# PATH reordering alone is not enough.
|
||||
# CMake игнорирует PATH при поиске Python через find_package(Python3),
|
||||
# поэтому явно указываем системный Python, где установлен catkin_pkg.
|
||||
# Одного изменения PATH недостаточно.
|
||||
env["Python3_EXECUTABLE"] = "/usr/bin/python3"
|
||||
env["PYTHON_EXECUTABLE"] = "/usr/bin/python3"
|
||||
|
||||
# Also keep PATH clean so other tools (rosdep, colcon itself) use system Python.
|
||||
# Заодно чистим PATH чтобы другие инструменты тоже использовали системный Python.
|
||||
# Keep PATH clean so other tools (rosdep, colcon itself) use system Python.
|
||||
# Чистим PATH чтобы другие инструменты тоже использовали системный Python.
|
||||
_SYSTEM_PATHS = ["/usr/bin", "/usr/local/bin"]
|
||||
existing = env.get("PATH", "").split(":")
|
||||
env["PATH"] = ":".join(
|
||||
@@ -113,393 +107,268 @@ def _ros2_env() -> dict:
|
||||
return env
|
||||
|
||||
|
||||
# Subprocess runner helpers
|
||||
# Вспомогательные функции для запуска подпроцессов
|
||||
def _run_logged(
|
||||
cmd: List[str],
|
||||
write: Write,
|
||||
env: dict | None = None,
|
||||
cwd=None,
|
||||
register_proc: Callable | None = None,
|
||||
) -> None:
|
||||
"""Run a command and stream every non-empty output line to the TUI log.
|
||||
|
||||
Raises RuntimeError if the process exits with a non-zero code (SIGKILL is
|
||||
treated as a normal cancellation and does not raise).
|
||||
|
||||
Запускает команду и передаёт каждую непустую строку вывода в лог TUI.
|
||||
Выбрасывает RuntimeError если процесс завершился с ненулевым кодом
|
||||
(SIGKILL считается нормальной отменой и не вызывает исключение).
|
||||
# Bash-script runner that understands PROGRESS:<pct>:<label> markers
|
||||
# Запуск bash-скриптов с поддержкой маркеров PROGRESS:<pct>:<метка>
|
||||
def _run_script(script: Path, title: str) -> int:
|
||||
"""Run a shell script, streaming its output to a live log and advancing the
|
||||
progress bar from PROGRESS:<pct>:<label> markers (which are not echoed raw).
|
||||
Returns the script exit code.
|
||||
Запускает shell-скрипт, транслируя вывод в живой лог и продвигая прогресс-бар по
|
||||
маркерам PROGRESS:<pct>:<метка> (сами маркеры не печатаются). Возвращает код возврата.
|
||||
"""
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
env=env or os.environ,
|
||||
cwd=cwd,
|
||||
)
|
||||
if register_proc:
|
||||
register_proc(proc)
|
||||
for line in proc.stdout:
|
||||
s = line.rstrip()
|
||||
if s:
|
||||
write(s)
|
||||
proc.wait()
|
||||
if proc.returncode not in (0, -9):
|
||||
raise RuntimeError(f"Command failed: {cmd[0]}")
|
||||
if not script.exists():
|
||||
header(title)
|
||||
ui.error(f"Скрипт не найден: {script}")
|
||||
done(False, "Скрипт отсутствует")
|
||||
return 1
|
||||
|
||||
|
||||
# Tasks - long-running functions executed inside a LogScreen background thread
|
||||
# Задачи - долгие функции, выполняемые в фоновом потоке внутри LogScreen
|
||||
def _run_script(script: Path, screen: LogScreen) -> None:
|
||||
"""Run a shell script, stream its output to the TUI log, and parse
|
||||
PROGRESS:<pct>:<label> markers to update the progress bar.
|
||||
Raises RuntimeError if the script exits with a non-zero code.
|
||||
Запускает shell-скрипт, транслирует вывод в лог TUI и разбирает маркеры
|
||||
PROGRESS:<pct>:<метка> для обновления прогресс-бара.
|
||||
Выбрасывает RuntimeError если скрипт завершился с ненулевым кодом.
|
||||
"""
|
||||
proc = subprocess.Popen(
|
||||
["bash", str(script)],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
text=True, cwd=_PROJECT_DIR,
|
||||
)
|
||||
screen.set_proc(proc)
|
||||
for line in proc.stdout:
|
||||
s = line.rstrip()
|
||||
with process.StepProgress(title) as p:
|
||||
def on_line(s: str) -> None:
|
||||
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 "")
|
||||
p.set(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).
|
||||
Скрипт выводит маркеры PROGRESS:, чтобы прогресс-бар обновлялся во время установки.
|
||||
"""
|
||||
try:
|
||||
# "desktop" -> setup_ros2_desktop.sh, "ros-base" -> setup_ros2_ros_base.sh
|
||||
script = _SCRIPTS_DIR / f"setup_ros2_{pkg.replace('-', '_')}.sh"
|
||||
if not script.exists():
|
||||
screen.write(f"[red]Script not found:[/red] {script}")
|
||||
screen.finish(False)
|
||||
return
|
||||
screen.set_progress(0, "Starting installation...")
|
||||
_run_script(script, screen)
|
||||
if not screen.is_stopped():
|
||||
screen.set_progress(100, "Done")
|
||||
screen.write(f"\n[green]ROS2 {_DISTRO} ({pkg}) installed successfully.[/green]")
|
||||
screen.finish(True)
|
||||
except Exception as exc:
|
||||
if not screen.is_stopped():
|
||||
screen.write(f"\n[red]Error:[/red] {exc}")
|
||||
screen.finish(False)
|
||||
if s:
|
||||
p.log(s)
|
||||
|
||||
rc = process.stream(["bash", str(script)], cwd=str(_PROJECT_DIR), on_line=on_line)
|
||||
|
||||
ok = rc in (0, -9, -15)
|
||||
done(ok, "Готово" if ok else f"Скрипт завершился с кодом {rc}")
|
||||
return rc
|
||||
|
||||
|
||||
def _task_build(screen: LogScreen) -> None:
|
||||
"""Build the project workspace using rosdep and colcon.
|
||||
|
||||
Step 1 - runs "rosdep install --from-paths src" to pull in all package
|
||||
dependencies declared in the src/ directory.
|
||||
Step 2 - runs "colcon build --symlink-install" to compile every package.
|
||||
|
||||
Both commands receive a copy of os.environ extended with the sourced ROS2
|
||||
setup so that ament CMake macros and ROS2 packages are visible even if the
|
||||
user has not yet sourced setup.bash in this terminal session.
|
||||
|
||||
Собирает рабочее пространство проекта с помощью rosdep и colcon.
|
||||
Шаг 1 - запускает "rosdep install --from-paths src" для установки всех
|
||||
зависимостей пакетов, объявленных в директории src/.
|
||||
Шаг 2 - запускает "colcon build --symlink-install" для компиляции каждого пакета.
|
||||
|
||||
Обе команды получают копию os.environ с подключённым окружением ROS2, так что
|
||||
макросы ament CMake и пакеты ROS2 видны даже если пользователь ещё не выполнил
|
||||
source setup.bash в этой сессии терминала.
|
||||
def install_ros2(pkg: str) -> bool:
|
||||
"""Run the ROS2 Jazzy install shell script for the chosen variant (desktop / ros-base).
|
||||
Запускает shell-скрипт установки ROS2 Jazzy для выбранного варианта (desktop / ros-base).
|
||||
"""
|
||||
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:
|
||||
current = sources_list.read_text() if sources_list.exists() else ""
|
||||
except Exception:
|
||||
current = ""
|
||||
if current == entry:
|
||||
return
|
||||
snippet = (
|
||||
"mkdir -p /etc/ros/rosdep/sources.list.d && "
|
||||
f"printf '%s\\n' 'yaml file://{rosdep_yaml}' > {sources_list}"
|
||||
)
|
||||
process.stream(privilege.sudo(["bash", "-c", snippet]), on_line=p.log)
|
||||
p.log(f"Зарегистрирован локальный источник rosdep: {rosdep_yaml}")
|
||||
process.stream(["rosdep", "update"], env=env, cwd=str(_PROJECT_DIR), on_line=p.log)
|
||||
|
||||
|
||||
def _count_colcon_packages(env: dict) -> int:
|
||||
"""Count colcon packages under src/ so the build bar can show X / total.
|
||||
Считает пакеты colcon в src/, чтобы бар сборки показывал X / всего.
|
||||
"""
|
||||
r = subprocess.run(
|
||||
["colcon", "list", "--base-paths", "src"],
|
||||
capture_output=True, text=True, cwd=str(_PROJECT_DIR), env=env,
|
||||
)
|
||||
return max(len([l for l in r.stdout.splitlines() if l.strip()]), 1)
|
||||
|
||||
|
||||
def build_workspace() -> bool:
|
||||
"""Build the workspace: apt prerequisites -> rosdep install -> colcon build.
|
||||
|
||||
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 компилирует все пакеты.
|
||||
"""
|
||||
env = _ros2_env()
|
||||
env["PIP_BREAK_SYSTEM_PACKAGES"] = "1"
|
||||
|
||||
if not shutil.which("colcon") and not Path(f"/opt/ros/{_DISTRO}/bin/colcon").exists():
|
||||
screen.write("[red]colcon not found.[/red]")
|
||||
screen.write(f"Source ROS2 first: [bold]source /opt/ros/{_DISTRO}/setup.bash[/bold]")
|
||||
screen.finish(False)
|
||||
return
|
||||
header("Сборка проекта")
|
||||
ui.error("colcon не найден.")
|
||||
ui.note(f"Сначала выполните: source /opt/ros/{_DISTRO}/setup.bash")
|
||||
done(False, "colcon недоступен")
|
||||
return False
|
||||
|
||||
screen.set_progress(0, "Installing dependencies...")
|
||||
screen.write("[bold]Step 1 / 2 - rosdep install[/bold]\n")
|
||||
ok = True
|
||||
fail_msg = ""
|
||||
|
||||
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,
|
||||
with process.StepProgress("Сборка проекта") as p:
|
||||
# --- Шаг 1/3: системные зависимости pip (apt) ---
|
||||
p.raw("[bold]Шаг 1/3 — системные зависимости pip (apt)[/bold]")
|
||||
p.set(0, "Проверка python3-pip / dev / venv...")
|
||||
missing = _missing_apt_prereqs()
|
||||
if missing:
|
||||
p.log(f"Установка: {', '.join(missing)}")
|
||||
process.stream(privilege.sudo(["apt-get", "update", "-q"]), env=env, on_line=p.log)
|
||||
rc = process.stream(
|
||||
privilege.sudo(["apt-get", "install", "-y", *missing]),
|
||||
env=env, on_line=p.log,
|
||||
)
|
||||
_run_logged(["rosdep", "update"], screen.write, env=env, cwd=_PROJECT_DIR)
|
||||
if rc not in (0, -9, -15):
|
||||
ok, fail_msg = False, "Не удалось установить apt-зависимости"
|
||||
else:
|
||||
p.log("python3-pip / dev / venv уже установлены")
|
||||
if ok:
|
||||
_ensure_root_pip_break(p)
|
||||
|
||||
_run_logged(
|
||||
# --- Шаг 2/3: rosdep install ---
|
||||
if ok:
|
||||
p.set(10, "rosdep install...")
|
||||
p.raw("\n[bold]Шаг 2/3 — rosdep install[/bold]")
|
||||
_register_rosdep_source(p, env)
|
||||
rc = process.stream(
|
||||
["rosdep", "install", "--from-paths", "src", "-i", "-r", "-y"],
|
||||
screen.write,
|
||||
env=env,
|
||||
cwd=_PROJECT_DIR,
|
||||
register_proc=screen.set_proc,
|
||||
env=env, cwd=str(_PROJECT_DIR), on_line=p.log,
|
||||
)
|
||||
if screen.is_stopped():
|
||||
return
|
||||
if rc not in (0, -9, -15):
|
||||
ok, fail_msg = False, "rosdep install завершился с ошибкой"
|
||||
|
||||
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")
|
||||
# --- Шаг 3/3: colcon build ---
|
||||
if ok:
|
||||
total = _count_colcon_packages(env)
|
||||
p.set(30, f"0 / {total} пакетов")
|
||||
p.raw(f"\n[bold]Шаг 3/3 — colcon build ({total} пакетов)[/bold]")
|
||||
built = 0
|
||||
|
||||
def _track(line: str) -> None:
|
||||
"""Update the progress bar each time colcon finishes a package.
|
||||
Обновляет прогресс-бар каждый раз, когда colcon завершает пакет.
|
||||
"""
|
||||
def _on_build(s: str) -> None:
|
||||
nonlocal built
|
||||
screen.write(line)
|
||||
if "Finished <<<" in line or "Failed <<<" in line:
|
||||
if s:
|
||||
p.log(s)
|
||||
if "Finished <<<" in s or "Failed <<<" in s:
|
||||
built += 1
|
||||
screen.set_progress(
|
||||
30 + built / total * 70,
|
||||
f"{built} / {total} packages done",
|
||||
)
|
||||
p.set(30 + built / total * 70, f"{built} / {total} пакетов")
|
||||
|
||||
_run_logged(
|
||||
rc = process.stream(
|
||||
["colcon", "build", "--base-paths", "src"],
|
||||
_track,
|
||||
env=env,
|
||||
cwd=_PROJECT_DIR,
|
||||
register_proc=screen.set_proc,
|
||||
env=env, cwd=str(_PROJECT_DIR), on_line=_on_build,
|
||||
)
|
||||
if rc not in (0, -9, -15):
|
||||
ok, fail_msg = False, "colcon build завершился с ошибкой"
|
||||
else:
|
||||
p.set(100, "Готово")
|
||||
|
||||
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)
|
||||
done(ok, "Сборка завершена" if ok else fail_msg)
|
||||
if ok:
|
||||
ui.note("Активируйте окружение: source install/setup.bash")
|
||||
return ok
|
||||
|
||||
|
||||
def _task_install_webots(screen: LogScreen) -> None:
|
||||
"""Run the Webots installation shell script, streaming output and progress to the TUI.
|
||||
|
||||
Запускает shell-скрипт установки Webots, транслируя вывод и прогресс в TUI.
|
||||
# Interactive flow
|
||||
# Интерактивный сценарий
|
||||
def run(args: argparse.Namespace) -> None:
|
||||
"""Guide the user through installing ROS2 Jazzy and building the workspace.
|
||||
Проводит пользователя через установку ROS2 Jazzy и сборку workspace.
|
||||
"""
|
||||
try:
|
||||
script = _SCRIPTS_DIR / "install_webots.sh"
|
||||
if not script.exists():
|
||||
screen.write(f"[red]Script not found:[/red] {script}")
|
||||
screen.finish(False)
|
||||
header("Локальная установка", "ROS2 Jazzy + сборка проекта")
|
||||
|
||||
choice = ui.select("Установить ROS2 Jazzy?", ["Да, установить", "Нет, выход"],
|
||||
"Да, установить")
|
||||
if not choice or choice.startswith("Нет"):
|
||||
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)
|
||||
|
||||
|
||||
# TUI application - orchestrates screens and user choices
|
||||
# TUI приложение - управляет экранами и выборами пользователя
|
||||
class _LocalSetupApp(App[Optional[str]]):
|
||||
"""Main TUI application for the local-setup command.
|
||||
|
||||
Guides the user through: install ROS2 choice, OS check, version choice,
|
||||
installation log, build log, and optional Webots installation.
|
||||
Returns "docker" if the user opts for Docker setup, None otherwise.
|
||||
|
||||
Главное TUI приложение для команды local-setup.
|
||||
Проводит пользователя через: выбор установки ROS2, проверку ОС, выбор версии,
|
||||
лог установки, лог сборки и опциональную установку Webots.
|
||||
Возвращает "docker" если пользователь выбирает Docker, иначе None.
|
||||
"""
|
||||
|
||||
CSS = SCREEN_CSS
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.push_screen(
|
||||
PickScreen(
|
||||
"local-setup",
|
||||
"Install ROS2 Jazzy?",
|
||||
["Yes, install", "No, exit"],
|
||||
"Yes, install",
|
||||
),
|
||||
self._on_install_choice,
|
||||
)
|
||||
|
||||
def _on_install_choice(self, choice: Optional[str]) -> None:
|
||||
"""Handle the initial yes/no choice to install ROS2.
|
||||
Обрабатывает начальный выбор да/нет для установки ROS2.
|
||||
"""
|
||||
if not choice or choice.startswith("No"):
|
||||
self.exit(None)
|
||||
# Acquire sudo once, up front, with the masked prompt + keep-alive thread.
|
||||
# Получаем sudo один раз, заранее, с маскированным вводом + keep-alive потоком.
|
||||
if not privilege.ensure_sudo():
|
||||
return
|
||||
|
||||
if not _detect_ubuntu_2404():
|
||||
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,
|
||||
v = ui.select(
|
||||
"Ubuntu 24.04 не обнаружена. Настроить окружение через Docker?",
|
||||
["Да, запустить docker-setup", "Нет, выход"],
|
||||
"Да, запустить docker-setup",
|
||||
)
|
||||
else:
|
||||
self.push_screen(
|
||||
PickScreen(
|
||||
"ROS2 version",
|
||||
"Which ROS2 Jazzy variant do you want to install?",
|
||||
["Desktop (full install, includes GUI tools)", "Base (minimal, no GUI)"],
|
||||
"Desktop (full install, includes GUI tools)",
|
||||
),
|
||||
self._on_version_choice,
|
||||
)
|
||||
|
||||
def _on_docker_choice(self, choice: Optional[str]) -> None:
|
||||
"""Exit the app signalling whether docker-setup should be launched.
|
||||
Завершает приложение, сигнализируя нужно ли запустить docker-setup.
|
||||
"""
|
||||
self.exit("docker" if choice and choice.startswith("Yes") else None)
|
||||
|
||||
def _on_version_choice(self, choice: Optional[str]) -> None:
|
||||
"""Start the installation log screen for the chosen ROS2 variant.
|
||||
Запускает экран лога установки для выбранного варианта ROS2.
|
||||
"""
|
||||
if not choice:
|
||||
self.exit(None)
|
||||
if v and v.startswith("Да"):
|
||||
_docker_setup(args)
|
||||
return
|
||||
pkg = "desktop" if choice.startswith("Desktop") else "ros-base"
|
||||
self.push_screen(
|
||||
LogScreen(
|
||||
f"Installing ROS2 Jazzy ({pkg})",
|
||||
lambda s: _task_install(s, pkg),
|
||||
show_progress=True,
|
||||
),
|
||||
lambda _: self._after_install(),
|
||||
)
|
||||
|
||||
def _after_install(self) -> None:
|
||||
"""After installation, ask whether to build the project workspace now.
|
||||
После установки спрашивает, нужно ли собрать рабочее пространство прямо сейчас.
|
||||
"""
|
||||
self.push_screen(
|
||||
PickScreen(
|
||||
"Build",
|
||||
"Build the project workspace now?\n(runs rosdep install + colcon build)",
|
||||
["Yes, build now", "No, skip"],
|
||||
"Yes, build now",
|
||||
),
|
||||
self._on_build_choice,
|
||||
variant = ui.select(
|
||||
"Какой вариант ROS2 Jazzy установить?",
|
||||
["Desktop (полный, с GUI-инструментами)", "Base (минимальный, без GUI)"],
|
||||
"Desktop (полный, с GUI-инструментами)",
|
||||
)
|
||||
|
||||
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()
|
||||
if not variant:
|
||||
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)
|
||||
pkg = "desktop" if variant.startswith("Desktop") else "ros-base"
|
||||
if not install_ros2(pkg):
|
||||
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:
|
||||
self.exit(None)
|
||||
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()
|
||||
|
||||
|
||||
class WebotsInstallApp(App[bool]):
|
||||
"""Standalone TUI app for installing Webots, used by the run command.
|
||||
|
||||
Launched by run.py when the user starts a local simulation but Webots
|
||||
is not installed yet.
|
||||
|
||||
Отдельное TUI приложение для установки Webots, используемое командой run.
|
||||
Запускается из run.py когда пользователь запускает локальную симуляцию,
|
||||
но Webots ещё не установлен.
|
||||
"""
|
||||
|
||||
CSS = SCREEN_CSS
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.push_screen(
|
||||
LogScreen(
|
||||
f"Installing Webots {_WEBOTS_VERSION}",
|
||||
_task_install_webots,
|
||||
show_progress=True,
|
||||
),
|
||||
self.exit,
|
||||
)
|
||||
|
||||
|
||||
# Entry point - registered as the "local-setup" subcommand
|
||||
# Точка входа - зарегистрирована как подкоманда "local-setup"
|
||||
def register(subparsers: argparse._SubParsersAction) -> None:
|
||||
"""Register the local-setup subcommand with the CLI argument parser.
|
||||
|
||||
Регистрирует подкоманду local-setup в парсере аргументов командной строки.
|
||||
"""
|
||||
p = subparsers.add_parser(
|
||||
@@ -507,21 +376,3 @@ def register(subparsers: argparse._SubParsersAction) -> None:
|
||||
help="Install ROS2 Jazzy natively and build the project with colcon",
|
||||
)
|
||||
p.set_defaults(func=run)
|
||||
|
||||
|
||||
def run(args: argparse.Namespace) -> None:
|
||||
"""Entry point for the local-setup command.
|
||||
|
||||
Pre-caches the sudo token while the terminal is in normal mode so that
|
||||
subsequent sudo calls inside the Textual TUI do not hang waiting for
|
||||
a password prompt that the user cannot see.
|
||||
|
||||
Точка входа для команды local-setup.
|
||||
Предварительно кеширует sudo-токен пока терминал в обычном режиме, чтобы
|
||||
последующие вызовы sudo внутри Textual TUI не зависали ожидая запрос пароля,
|
||||
который пользователь не может увидеть.
|
||||
"""
|
||||
subprocess.run(["sudo", "-v"], check=False)
|
||||
result = _LocalSetupApp().run()
|
||||
if result == "docker":
|
||||
_docker_setup(args)
|
||||
|
||||
+55
-130
@@ -5,40 +5,35 @@ import subprocess
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from textual.app import App
|
||||
|
||||
from cobot.tui import SCREEN_CSS, InputScreen, LogScreen, PickScreen
|
||||
from cobot import ui, process
|
||||
from cobot.commands.local_setup import _ros2_env
|
||||
|
||||
_PROJECT_DIR = Path(__file__).parent.parent.parent
|
||||
|
||||
|
||||
def _task_rebuild(screen: LogScreen, packages: List[str], symlink: bool) -> None:
|
||||
"""Run colcon build for the selected packages (or all if packages is empty).
|
||||
Streams output to the log and tracks per-package progress.
|
||||
|
||||
Запускает colcon build для выбранных пакетов (или всех если packages пуст).
|
||||
Транслирует вывод в лог и отслеживает прогресс по каждому пакету.
|
||||
def _count_packages(packages: List[str], env: dict) -> int:
|
||||
"""Count how many colcon packages will be built so we can show X / total progress.
|
||||
Считает количество пакетов colcon для отображения прогресса X / всего.
|
||||
"""
|
||||
try:
|
||||
env = _ros2_env()
|
||||
|
||||
# Count packages so we can show X / total progress.
|
||||
# Считаем пакеты чтобы показывать X / всего в прогрессе.
|
||||
list_cmd = ["colcon", "list", "--base-paths", "src"]
|
||||
if packages:
|
||||
list_cmd += ["--packages-select"] + packages
|
||||
list_result = subprocess.run(
|
||||
list_cmd, capture_output=True, text=True,
|
||||
cwd=_PROJECT_DIR, env=env,
|
||||
)
|
||||
total = max(len([l for l in list_result.stdout.splitlines() if l.strip()]), 1)
|
||||
result = subprocess.run(list_cmd, capture_output=True, text=True,
|
||||
cwd=_PROJECT_DIR, env=env)
|
||||
return max(len([l for l in result.stdout.splitlines() if l.strip()]), 1)
|
||||
|
||||
pkg_label = " ".join(packages) if packages else "all packages"
|
||||
|
||||
def _rebuild(packages: List[str], symlink: bool) -> None:
|
||||
"""Run colcon build for the selected packages (or all), streaming live output
|
||||
with a per-package progress bar.
|
||||
Запускает colcon build для выбранных пакетов (или всех), транслируя живой вывод
|
||||
с прогресс-баром по пакетам.
|
||||
"""
|
||||
env = _ros2_env()
|
||||
total = _count_packages(packages, env)
|
||||
|
||||
pkg_label = " ".join(packages) if packages else "все пакеты"
|
||||
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:
|
||||
@@ -46,113 +41,27 @@ def _task_rebuild(screen: LogScreen, packages: List[str], symlink: bool) -> None
|
||||
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)
|
||||
built = 0
|
||||
|
||||
for line in proc.stdout:
|
||||
s = line.rstrip()
|
||||
if s:
|
||||
screen.write(s)
|
||||
def _parse(line: str):
|
||||
nonlocal built
|
||||
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]):
|
||||
"""Rebuild wizard: optionally asks for packages and symlink flag, then runs colcon.
|
||||
Мастер пересборки: опционально спрашивает пакеты и флаг symlink, затем запускает colcon.
|
||||
"""
|
||||
|
||||
CSS = SCREEN_CSS
|
||||
|
||||
def __init__(self, packages: Optional[List[str]], symlink: Optional[bool]):
|
||||
super().__init__()
|
||||
# None means "ask the user interactively".
|
||||
# None означает "спросить пользователя интерактивно".
|
||||
self._packages = packages
|
||||
self._symlink = symlink
|
||||
|
||||
def on_mount(self) -> None:
|
||||
if self._packages is None:
|
||||
self._ask_packages()
|
||||
elif self._symlink is None:
|
||||
self._ask_symlink()
|
||||
else:
|
||||
self._start()
|
||||
|
||||
def _ask_packages(self) -> None:
|
||||
self.push_screen(
|
||||
InputScreen(
|
||||
"rebuild",
|
||||
"Packages to rebuild (space-separated, leave empty for all):",
|
||||
"",
|
||||
note="Example: iiwa_controller iiwa_bringup",
|
||||
),
|
||||
self._got_packages,
|
||||
)
|
||||
|
||||
def _got_packages(self, value: Optional[str]) -> None:
|
||||
if value is None:
|
||||
self.exit()
|
||||
return
|
||||
self._packages = value.split() if value.strip() else []
|
||||
if self._symlink is None:
|
||||
self._ask_symlink()
|
||||
else:
|
||||
self._start()
|
||||
|
||||
def _ask_symlink(self) -> None:
|
||||
self.push_screen(
|
||||
PickScreen(
|
||||
"rebuild",
|
||||
"Use --symlink-install?",
|
||||
["Yes", "No"],
|
||||
"Yes",
|
||||
),
|
||||
self._got_symlink,
|
||||
)
|
||||
|
||||
def _got_symlink(self, value: Optional[str]) -> None:
|
||||
if value is None:
|
||||
self.exit()
|
||||
return
|
||||
self._symlink = value == "Yes"
|
||||
self._start()
|
||||
|
||||
def _start(self) -> None:
|
||||
self.push_screen(
|
||||
LogScreen(
|
||||
"Rebuilding packages",
|
||||
lambda s: _task_rebuild(s, self._packages, self._symlink),
|
||||
show_progress=True,
|
||||
),
|
||||
lambda _: self.exit(),
|
||||
return (built / total * 100, f"{built} / {total} пакетов")
|
||||
return None
|
||||
|
||||
rc = process.run_step(
|
||||
f"colcon build{symlink_label} — {pkg_label}",
|
||||
cmd,
|
||||
env=env,
|
||||
cwd=str(_PROJECT_DIR),
|
||||
total=100.0,
|
||||
parse_progress=_parse,
|
||||
success_msg="Сборка завершена",
|
||||
fail_msg="Сборка завершилась с ошибкой",
|
||||
)
|
||||
if rc in (0, -9, -15):
|
||||
ui.note("Активируйте окружение: source install/setup.bash")
|
||||
|
||||
|
||||
def register(subparsers: argparse._SubParsersAction) -> None:
|
||||
@@ -186,7 +95,23 @@ def run(args: argparse.Namespace) -> None:
|
||||
"""Entry point for the rebuild command.
|
||||
Точка входа для команды rebuild.
|
||||
"""
|
||||
# Convert empty list to None so the TUI asks interactively.
|
||||
# Преобразуем пустой список в None, чтобы TUI спросил интерактивно.
|
||||
packages = args.packages if args.packages else None
|
||||
_RebuildApp(packages, args.symlink).run()
|
||||
packages: Optional[List[str]] = args.packages if args.packages else None
|
||||
|
||||
if packages is None:
|
||||
value = ui.text(
|
||||
"Какие пакеты пересобрать? (через пробел, пусто — все)",
|
||||
"",
|
||||
note="Пример: iiwa_controller iiwa_bringup",
|
||||
)
|
||||
if value is None:
|
||||
return
|
||||
packages = value.split() if value.strip() else []
|
||||
|
||||
symlink = args.symlink
|
||||
if symlink is None:
|
||||
choice = ui.select("Использовать --symlink-install?", ["Да", "Нет"], "Да")
|
||||
if choice is None:
|
||||
return
|
||||
symlink = choice == "Да"
|
||||
|
||||
_rebuild(packages, symlink)
|
||||
|
||||
+145
-228
@@ -2,17 +2,13 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, List, Optional, Tuple
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from ruamel.yaml import YAML
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import Footer, Static
|
||||
|
||||
from cobot.tui import SCREEN_CSS, InputScreen, PickScreen
|
||||
from cobot import ui
|
||||
|
||||
_PROJECT_DIR = Path(__file__).parent.parent.parent
|
||||
_CONFIG_PATH = _PROJECT_DIR / "cobot-setting.yaml"
|
||||
@@ -23,34 +19,28 @@ _TOOL_ACTIVE_XACRO = (
|
||||
)
|
||||
_SRDF_PATH = _PROJECT_DIR / "src" / "iiwa_config" / "config" / "moveit" / "iiwa7.srdf"
|
||||
|
||||
# Use ruamel.yaml instead of PyYAML so comments and formatting in the config file are preserved.
|
||||
# Use ruamel.yaml instead of PyYAML so comments and formatting in the config are preserved.
|
||||
# Используем ruamel.yaml вместо PyYAML, чтобы комментарии и форматирование в конфиге сохранялись.
|
||||
_yaml = YAML()
|
||||
_yaml.preserve_quotes = True
|
||||
|
||||
|
||||
# One question inside a configuration block.
|
||||
# A field can either show a pick list (options) or a free-text input (no options).
|
||||
# Один вопрос внутри блока конфигурации.
|
||||
# Поле может показывать список вариантов (options) или поле для ввода текста (без options).
|
||||
@dataclass
|
||||
class _Field:
|
||||
key: str # dot-separated path within the block, e.g. "webots.world"
|
||||
question: str
|
||||
default: Any
|
||||
note: str = ""
|
||||
options: Optional[List[str]] = None # if set - PickScreen, else - InputScreen
|
||||
options: Optional[List[str]] = None # if set - select(), else - text()
|
||||
|
||||
def label(self) -> str:
|
||||
return self.key.split(".")[-1]
|
||||
|
||||
|
||||
# A group of related fields shown together under one "Configure X?" question.
|
||||
# Группа связанных полей, показываемая вместе под одним вопросом "Настроить X?".
|
||||
@dataclass
|
||||
class _Block:
|
||||
yaml_key: str # top-level key in cobot-setting.yaml
|
||||
title: str # shown in "Configure <title>?" prompt
|
||||
title: str # shown in "Настроить <title>?" prompt
|
||||
fields: List[_Field]
|
||||
|
||||
|
||||
@@ -76,15 +66,10 @@ def _build_tool_block() -> Optional[_Block]:
|
||||
)
|
||||
return _Block(
|
||||
yaml_key="tool",
|
||||
title="Tool / End-effector",
|
||||
title="Инструмент / Захват",
|
||||
fields=[
|
||||
_Field(
|
||||
"active",
|
||||
"Выберите активный инструмент:",
|
||||
options[0],
|
||||
note=labels,
|
||||
options=options,
|
||||
),
|
||||
_Field("active", "Выберите активный инструмент:", options[0],
|
||||
note=labels, options=options),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -96,76 +81,83 @@ _BLOCKS: List[_Block] = [
|
||||
yaml_key="foxglove",
|
||||
title="Foxglove bridge",
|
||||
fields=[
|
||||
_Field("enabled", "Enable Foxglove bridge?", "true",
|
||||
note="Start foxglove_bridge alongside the robot node",
|
||||
_Field("enabled", "Включить Foxglove bridge?", "true",
|
||||
note="Запускать foxglove_bridge вместе с узлом робота",
|
||||
options=["true", "false"]),
|
||||
_Field("port", "WebSocket port:", "8765",
|
||||
note="Port Foxglove Studio connects to (default 8765)"),
|
||||
_Field("address", "Listen address:", "0.0.0.0",
|
||||
note="0.0.0.0 = all interfaces, 127.0.0.1 = localhost only",
|
||||
_Field("port", "Порт WebSocket:", "8765",
|
||||
note="Порт, к которому подключается Foxglove Studio (по умолчанию 8765)"),
|
||||
_Field("address", "Адрес прослушивания:", "0.0.0.0",
|
||||
note="0.0.0.0 = все интерфейсы, 127.0.0.1 = только localhost",
|
||||
options=["0.0.0.0", "127.0.0.1"]),
|
||||
_Field("use_sim_time", "Use simulation time (/clock)?", "false",
|
||||
note="Subscribe to /clock instead of using wall time",
|
||||
_Field("use_sim_time", "Использовать симуляционное время (/clock)?", "false",
|
||||
note="Подписываться на /clock вместо системного времени",
|
||||
options=["false", "true"]),
|
||||
_Field("debug", "Enable verbose bridge logging?", "false",
|
||||
_Field("debug", "Подробное логирование bridge?", "false",
|
||||
options=["false", "true"]),
|
||||
_Field("num_threads", "Executor threads (0 = auto):", "0"),
|
||||
_Field("num_threads", "Потоки executor (0 = авто):", "0"),
|
||||
],
|
||||
),
|
||||
_Block(
|
||||
yaml_key="web",
|
||||
title="Веб-сервер (FastAPI)",
|
||||
fields=[
|
||||
_Field("enabled", "Включить веб-сервер?", "true",
|
||||
note="Запускать FastAPI-сервер для HTTP/WebSocket-управления",
|
||||
options=["true", "false"]),
|
||||
_Field("host", "Адрес прослушивания:", "0.0.0.0",
|
||||
note="0.0.0.0 = все интерфейсы, 127.0.0.1 = только localhost",
|
||||
options=["0.0.0.0", "127.0.0.1"]),
|
||||
_Field("port", "Порт HTTP:", "8007",
|
||||
note="Порт FastAPI-сервера (по умолчанию 8007)"),
|
||||
],
|
||||
),
|
||||
_Block(
|
||||
yaml_key="planning",
|
||||
title="MoveIt planning",
|
||||
title="MoveIt планирование",
|
||||
fields=[
|
||||
_Field("planning_group", "Planning group:", "iiwa_arm",
|
||||
note="MoveIt planning group as defined in the SRDF"),
|
||||
_Field("default_frame", "Default reference frame:", "base_link"),
|
||||
_Field("default_planner", "Default planner:", "ompl",
|
||||
_Field("planning_group", "Группа планирования:", "iiwa_arm",
|
||||
note="Группа планирования MoveIt из SRDF"),
|
||||
_Field("default_frame", "Система отсчёта по умолчанию:", "base_link"),
|
||||
_Field("default_planner", "Планировщик по умолчанию:", "ompl",
|
||||
options=["ompl", "pilz_industrial_motion_planner", "chomp"]),
|
||||
_Field("planning_attempts", "Planning attempts:", "3"),
|
||||
_Field("planning_attempts", "Попыток планирования:", "3"),
|
||||
],
|
||||
),
|
||||
_Block(
|
||||
yaml_key="digital_twin",
|
||||
title="Digital twin (Webots / RViz)",
|
||||
title="Цифровой двойник (Webots / RViz)",
|
||||
fields=[
|
||||
_Field("webots.transform", "Robot transform in Webots scene (x y z, metres):", "-0.25 0 0.79"),
|
||||
_Field("webots.rotation", "Robot rotation in Webots scene (ax ay az angle):", "0 0 1 0"),
|
||||
_Field("webots.controller_timer", "Webots controller step timer (ms):", "50"),
|
||||
_Field("webots.transform", "Трансформ робота в сцене Webots (x y z, метры):", "-0.25 0 0.79"),
|
||||
_Field("webots.rotation", "Поворот робота в сцене Webots (ax ay az угол):", "0 0 1 0"),
|
||||
_Field("webots.controller_timer", "Шаг таймера контроллера Webots (мс):", "50"),
|
||||
],
|
||||
),
|
||||
_Block(
|
||||
yaml_key="robot",
|
||||
title="Robot connection",
|
||||
title="Подключение робота",
|
||||
fields=[
|
||||
_Field("name", "Robot model name:", "iiwa7"),
|
||||
_Field("ip", "Robot IP address:", "192.170.10.2",
|
||||
note="IP of the KUKA controller on the FRI network interface"),
|
||||
_Field("port", "FRI port:", "30200"),
|
||||
_Field("command_mode", "Command mode:", "position",
|
||||
note="position = joint position control, torque = joint torque control",
|
||||
options=["position", "torque"]),
|
||||
_Field("fri_cycle_ms", "FRI cycle time (ms):", "10",
|
||||
note="5 ms = 200 Hz, 10 ms = 100 Hz",
|
||||
_Field("name", "Имя модели робота:", "iiwa7"),
|
||||
_Field("ip", "IP-адрес робота:", "192.170.10.2",
|
||||
note="IP контроллера KUKA на сетевом интерфейсе FRI"),
|
||||
_Field("port", "Порт FRI:", "30200"),
|
||||
_Field("fri_cycle_ms", "Цикл FRI (мс):", "10",
|
||||
note="5 мс = 200 Гц, 10 мс = 100 Гц",
|
||||
options=["10", "5"]),
|
||||
_Field("active_controller", "Active ROS controller:", "jtc",
|
||||
_Field("active_controller", "Активный ROS-контроллер:", "jtc",
|
||||
note="jtc = JointTrajectoryController (MoveIt), forward = ForwardCommandController",
|
||||
options=["jtc", "forward"]),
|
||||
_Field("joint_position_tau", "Position EMA filter tau (s):", "0.04",
|
||||
note="Smooths position commands before sending to FRI"),
|
||||
_Field("joint_velocity_tau", "Velocity EMA filter tau (s):", "0.01",
|
||||
note="Removes spikes from finite-difference velocity estimation"),
|
||||
_Field("joint_position_tau", "EMA tau фильтра положения (с):", "0.04",
|
||||
note="Сглаживает команды положения перед отправкой в FRI"),
|
||||
_Field("joint_velocity_tau", "EMA tau фильтра скорости (с):", "0.01",
|
||||
note="Убирает выбросы из оценки скорости конечной разностью"),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
# Try to keep the original YAML type (bool, int, float) when saving a value back.
|
||||
# Trying to preserve type prevents "true" from becoming a plain string in the YAML file.
|
||||
# Пытаемся сохранить исходный тип YAML (bool, int, float) при записи значения обратно.
|
||||
# Сохранение типа предотвращает превращение "true" в обычную строку в YAML-файле.
|
||||
def _coerce(value: str, original: Any) -> Any:
|
||||
"""Convert a string value to match the type of the original YAML value (bool, int, float, str).
|
||||
Преобразует строковое значение к типу исходного значения YAML (bool, int, float, str).
|
||||
"""Convert a string value to match the type of the original YAML value.
|
||||
Преобразует строковое значение к типу исходного значения YAML.
|
||||
"""
|
||||
if isinstance(original, bool):
|
||||
return value.lower() == "true"
|
||||
@@ -182,11 +174,9 @@ def _coerce(value: str, original: Any) -> Any:
|
||||
return value
|
||||
|
||||
|
||||
# Read a value from a nested YAML mapping using a dot-separated key like "webots.transform".
|
||||
# Читаем значение из вложенного YAML-словаря по ключу с точками, например "webots.transform".
|
||||
def _get_nested(mapping: Any, path: str) -> Any:
|
||||
"""Return the value at a dot-separated path inside a nested YAML mapping, or None if missing.
|
||||
Возвращает значение по пути с точками внутри вложенного YAML-словаря, или None если отсутствует.
|
||||
"""Return the value at a dot-separated path inside a nested YAML mapping, or None.
|
||||
Возвращает значение по пути с точками внутри вложенного YAML-словаря, или None.
|
||||
"""
|
||||
keys = path.split(".")
|
||||
cur = mapping
|
||||
@@ -197,172 +187,108 @@ def _get_nested(mapping: Any, path: str) -> Any:
|
||||
return cur
|
||||
|
||||
|
||||
# Write a value into a nested YAML mapping using a dot-separated key.
|
||||
# Записываем значение в вложенный YAML-словарь по ключу с точками.
|
||||
def _infer(value: str) -> Any:
|
||||
"""Infer a bool / int / float / str from a raw string when there is no original
|
||||
value to match the type against (i.e. the key is new in the config).
|
||||
Выводит bool / int / float / str из строки, когда нет исходного значения для
|
||||
сопоставления типа (т.е. ключ новый в конфиге).
|
||||
"""
|
||||
low = value.strip().lower()
|
||||
if low in ("true", "false"):
|
||||
return low == "true"
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
return float(value)
|
||||
except ValueError:
|
||||
pass
|
||||
return value
|
||||
|
||||
|
||||
def _set_nested(mapping: Any, path: str, value: Any) -> None:
|
||||
"""Set the value at a dot-separated path inside a nested YAML mapping, coercing type to match.
|
||||
Устанавливает значение по пути с точками во вложенном YAML-словаре, приводя тип к исходному.
|
||||
"""Set the value at a dot-separated path, creating missing intermediate maps.
|
||||
|
||||
If the leaf key already exists its type is preserved via _coerce; otherwise the
|
||||
type is inferred from the string with _infer. This makes the wizard tolerant of
|
||||
configs that do not yet contain every field (e.g. an older cobot-setting.yaml).
|
||||
|
||||
Устанавливает значение по пути с точками, создавая отсутствующие промежуточные
|
||||
словари. Если конечный ключ уже есть — тип сохраняется через _coerce; иначе тип
|
||||
выводится из строки через _infer. Это делает мастер устойчивым к конфигам, где
|
||||
ещё нет всех полей (например, более старый cobot-setting.yaml).
|
||||
"""
|
||||
keys = path.split(".")
|
||||
cur = mapping
|
||||
for k in keys[:-1]:
|
||||
if k not in cur or cur[k] is None:
|
||||
cur[k] = {}
|
||||
cur = cur[k]
|
||||
original = cur[keys[-1]]
|
||||
cur[keys[-1]] = _coerce(value, original)
|
||||
|
||||
|
||||
# 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()
|
||||
leaf = keys[-1]
|
||||
if leaf in cur and cur[leaf] is not None:
|
||||
cur[leaf] = _coerce(value, cur[leaf])
|
||||
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()
|
||||
cur[leaf] = _infer(value)
|
||||
|
||||
|
||||
# Load the config file preserving all comments and key order.
|
||||
# Загружаем конфиг-файл, сохраняя все комментарии и порядок ключей.
|
||||
def _load_config() -> Any:
|
||||
"""Load cobot-setting.yaml with ruamel.yaml, preserving comments and key order.
|
||||
Загружает cobot-setting.yaml с помощью ruamel.yaml, сохраняя комментарии и порядок ключей.
|
||||
"""Load cobot-setting.yaml preserving comments and key order.
|
||||
Загружает cobot-setting.yaml, сохраняя комментарии и порядок ключей.
|
||||
"""
|
||||
with open(_CONFIG_PATH, "r", encoding="utf-8") as fh:
|
||||
return _yaml.load(fh)
|
||||
|
||||
|
||||
# Write the modified config back to disk preserving comments and formatting.
|
||||
# Записываем изменённый конфиг обратно на диск, сохраняя комментарии и форматирование.
|
||||
def _save_config(data: Any) -> None:
|
||||
"""Write the modified YAML data back to cobot-setting.yaml, preserving comments.
|
||||
Записывает изменённые данные YAML обратно в cobot-setting.yaml, сохраняя комментарии.
|
||||
"""Write the modified YAML back to cobot-setting.yaml, preserving comments.
|
||||
Записывает изменённый YAML обратно в cobot-setting.yaml, сохраняя комментарии.
|
||||
"""
|
||||
with open(_CONFIG_PATH, "w", encoding="utf-8") as fh:
|
||||
_yaml.dump(data, fh)
|
||||
|
||||
|
||||
def _run_wizard(data: Any, blocks: List[_Block]) -> bool:
|
||||
"""Walk every block: ask "Configure X?", and if yes step through its fields.
|
||||
Returns True if the user completed the wizard (config was saved), False on cancel.
|
||||
Проходит по каждому блоку: спрашивает "Настроить X?", и если да — проходит по полям.
|
||||
Возвращает True если мастер завершён (конфиг сохранён), False при отмене.
|
||||
"""
|
||||
total = len(blocks)
|
||||
for bi, block in enumerate(blocks):
|
||||
ui.header(f"Блок {bi + 1}/{total}", block.title)
|
||||
if not ui.confirm(f"Настроить {block.title}?", default=True):
|
||||
continue
|
||||
for fi, f in enumerate(block.fields):
|
||||
yaml_val = _get_nested(data[block.yaml_key], f.key)
|
||||
current = str(yaml_val) if yaml_val is not None else str(f.default)
|
||||
step_note = f"Поле {fi + 1}/{len(block.fields)}"
|
||||
note = f"{step_note}\n{f.note}" if f.note else step_note
|
||||
if f.options:
|
||||
default_opt = current if current in f.options else f.options[0]
|
||||
value = ui.select(f.question, f.options, default_opt, note=note)
|
||||
else:
|
||||
value = ui.text(f.question, current, note=note)
|
||||
if value is None:
|
||||
ui.info("[yellow]Отменено.[/yellow]")
|
||||
return False
|
||||
_set_nested(data[block.yaml_key], f.key, value)
|
||||
|
||||
_save_config(data)
|
||||
ui.done(True, f"Конфигурация сохранена в {_CONFIG_PATH.name}")
|
||||
return True
|
||||
|
||||
|
||||
def register(subparsers: argparse._SubParsersAction) -> None:
|
||||
p = subparsers.add_parser("robot-setup", help="Configure cobot-setting.yaml interactively")
|
||||
p.set_defaults(func=run)
|
||||
|
||||
|
||||
def run(args: argparse.Namespace) -> None:
|
||||
from rich.console import Console
|
||||
console = Console()
|
||||
ui.header("Настройка робота", "cobot-setting.yaml")
|
||||
|
||||
if not _CONFIG_PATH.exists():
|
||||
console.print(f"[red]Config not found:[/red] {_CONFIG_PATH}")
|
||||
ui.error(f"Конфиг не найден: {_CONFIG_PATH}")
|
||||
sys.exit(1)
|
||||
|
||||
data = _load_config()
|
||||
@@ -372,33 +298,26 @@ def run(args: argparse.Namespace) -> None:
|
||||
data["tool"] = {"active": "patron"}
|
||||
|
||||
tool_block = _build_tool_block()
|
||||
extra = [tool_block] if tool_block else []
|
||||
blocks = ([tool_block] if tool_block else []) + list(_BLOCKS)
|
||||
|
||||
wizard = _Wizard(data, extra_blocks=extra)
|
||||
wizard.run()
|
||||
|
||||
if not wizard.did_save:
|
||||
if not _run_wizard(data, blocks):
|
||||
return
|
||||
|
||||
# Применяем выбранный инструмент: перезаписываем tool_active.xacro и iiwa7.srdf
|
||||
active_tool = str(data["tool"].get("active", "patron"))
|
||||
if _TOOLS_YAML.exists():
|
||||
if not _TOOLS_YAML.exists():
|
||||
ui.info(f"[yellow]tools.yaml не найден ({_TOOLS_YAML}) — пропуск применения инструмента[/yellow]")
|
||||
return
|
||||
|
||||
try:
|
||||
registry = _load_tools_registry()
|
||||
if active_tool not in registry:
|
||||
raise ValueError(
|
||||
f"Unknown tool '{active_tool}'. Available: {', '.join(registry)}"
|
||||
)
|
||||
raise ValueError(f"Неизвестный инструмент '{active_tool}'. Доступны: {', '.join(registry)}")
|
||||
tool_cfg = dict(registry[active_tool])
|
||||
|
||||
import sys as _sys
|
||||
_sys.path.insert(0, str(_PROJECT_DIR / "src" / "iiwa_utils"))
|
||||
sys.path.insert(0, str(_PROJECT_DIR / "src" / "iiwa_utils"))
|
||||
from iiwa_utils.tool_manager import apply_tool
|
||||
apply_tool(
|
||||
tool_cfg=tool_cfg,
|
||||
xacro_out_path=_TOOL_ACTIVE_XACRO,
|
||||
srdf_path=_SRDF_PATH,
|
||||
)
|
||||
apply_tool(tool_cfg=tool_cfg, xacro_out_path=_TOOL_ACTIVE_XACRO, srdf_path=_SRDF_PATH)
|
||||
|
||||
# Синхронизируем planning.pose_link с tcp_link выбранного инструмента
|
||||
tcp_link = tool_cfg.get("tcp_link", "link_ee")
|
||||
@@ -406,12 +325,10 @@ def run(args: argparse.Namespace) -> None:
|
||||
data["planning"]["pose_link"] = tcp_link
|
||||
_save_config(data)
|
||||
|
||||
console.print(
|
||||
f"[green]✓[/green] Tool [bold]{active_tool}[/bold] applied: "
|
||||
f"tool_active.xacro, iiwa7.srdf updated, "
|
||||
ui.info(
|
||||
f"[green]✓[/green] Инструмент [bold]{active_tool}[/bold] применён: "
|
||||
f"tool_active.xacro, iiwa7.srdf обновлены, "
|
||||
f"planning.pose_link → [bold]{tcp_link}[/bold]."
|
||||
)
|
||||
except Exception as exc:
|
||||
console.print(f"[red]Tool apply failed:[/red] {exc}")
|
||||
else:
|
||||
console.print(f"[yellow]tools.yaml not found at {_TOOLS_YAML} — skipping tool apply[/yellow]")
|
||||
ui.error(f"Не удалось применить инструмент: {exc}")
|
||||
|
||||
+85
-319
@@ -2,17 +2,21 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import signal
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Callable, List, Optional
|
||||
from typing import List, Optional
|
||||
|
||||
from textual.app import App
|
||||
|
||||
from cobot.tui import SCREEN_CSS, LogScreen, PickScreen, RunScreen
|
||||
from cobot.commands.local_setup import webots_installed, WebotsInstallApp, _WEBOTS_VERSION
|
||||
from cobot import process, ui
|
||||
from cobot import privilege
|
||||
from cobot.ui import done, header
|
||||
from cobot.commands.local_setup import (
|
||||
_WEBOTS_VERSION,
|
||||
build_workspace,
|
||||
install_webots,
|
||||
webots_installed,
|
||||
)
|
||||
|
||||
_PROJECT_DIR = Path(__file__).parent.parent.parent
|
||||
_CONFIG_PATH = _PROJECT_DIR / "cobot-setting.yaml"
|
||||
@@ -32,15 +36,11 @@ _CONTAINER_CONTROLLER = "lwc-controller"
|
||||
_CONTAINER_WEBOTS = "lwc-webots"
|
||||
|
||||
# Named Docker volume that stores the Webots asset cache between container runs.
|
||||
# Without it Webots re-downloads all 3D assets from the internet on every launch.
|
||||
# Именованный Docker volume для хранения кэша ассетов Webots между запусками контейнера.
|
||||
# Без него Webots заново скачивает все 3D-ассеты из интернета при каждом запуске.
|
||||
_WEBOTS_CACHE_VOLUME = "lwc-webots-cache"
|
||||
|
||||
# Candidates checked in order - for the controller the webots image is a valid fallback
|
||||
# because it already contains all controller packages too.
|
||||
# Кандидаты проверяются по порядку - для контроллера образ webots является допустимым запасным,
|
||||
# так как он уже содержит все пакеты контроллера.
|
||||
# Candidates checked in order - for the controller the webots image is a valid fallback.
|
||||
# Кандидаты проверяются по порядку - для контроллера образ webots является допустимым запасным.
|
||||
_CONTROLLER_IMAGES = [
|
||||
"lwc-local:ros-iiwa7-jazzy",
|
||||
"evilfisru/lwc:iiwa-jazzy",
|
||||
@@ -56,56 +56,16 @@ _WEBOTS_IMAGES = [
|
||||
]
|
||||
|
||||
|
||||
# A minimal app that asks one question and exits immediately with the chosen value.
|
||||
# We need a full App because Textual screens cannot run outside one.
|
||||
# Минимальное приложение, которое задаёт один вопрос и сразу выходит с выбранным значением.
|
||||
# Нам нужен полноценный App, потому что экраны Textual не могут работать вне него.
|
||||
class _Ask(App[Optional[str]]):
|
||||
"""Minimal one-question Textual app. Pushes a PickScreen and exits with the chosen value.
|
||||
Минимальное однвопросное Textual-приложение. Открывает PickScreen и завершается с выбранным значением.
|
||||
"""
|
||||
CSS = SCREEN_CSS
|
||||
|
||||
def __init__(self, step: str, question: str, options: List[str], default: str):
|
||||
super().__init__()
|
||||
self._step = step
|
||||
self._question = question
|
||||
self._options = options
|
||||
self._default = default
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.push_screen(
|
||||
PickScreen(self._step, self._question, self._options, self._default),
|
||||
self.exit,
|
||||
)
|
||||
|
||||
|
||||
def _ask(step: str, question: str, options: List[str], default: str) -> Optional[str]:
|
||||
"""Show a single-choice PickScreen and return the selected value, or None on Escape.
|
||||
Показывает PickScreen с одним выбором и возвращает выбранное значение или None при Escape.
|
||||
"""
|
||||
# Returns None when the user pressed Escape to cancel.
|
||||
# Возвращает None когда пользователь нажал Escape для отмены.
|
||||
return _Ask(step, question, options, default).run()
|
||||
|
||||
|
||||
def _detect_webots_home() -> str:
|
||||
"""Return the WEBOTS_HOME path for the locally installed Linux Webots.
|
||||
|
||||
Validates that the candidate directory contains the Linux 'webots' binary
|
||||
|
||||
Возвращает путь WEBOTS_HOME для локально установленного Linux Webots.
|
||||
"""Return the WEBOTS_HOME path for the locally installed Linux Webots, or "".
|
||||
Возвращает путь WEBOTS_HOME для локально установленного Linux Webots, или "".
|
||||
"""
|
||||
def _is_linux_webots(home: str) -> bool:
|
||||
# Accept only directories that have the Linux 'webots' binary directly inside.
|
||||
# Принимаем только директории с Linux-бинарником 'webots' напрямую внутри.
|
||||
return (Path(home) / "webots").is_file()
|
||||
|
||||
if "WEBOTS_HOME" in os.environ:
|
||||
home = os.environ["WEBOTS_HOME"]
|
||||
if _is_linux_webots(home):
|
||||
return home
|
||||
return ""
|
||||
return home if _is_linux_webots(home) else ""
|
||||
if _WEBOTS_DEFAULT_HOME.is_dir() and _is_linux_webots(str(_WEBOTS_DEFAULT_HOME)):
|
||||
return str(_WEBOTS_DEFAULT_HOME)
|
||||
webots_bin = shutil.which("webots")
|
||||
@@ -116,10 +76,8 @@ def _detect_webots_home() -> str:
|
||||
return ""
|
||||
|
||||
|
||||
# Detect the GPU type so we can pass the right flags to docker run for Webots rendering.
|
||||
# Определяем тип GPU, чтобы передать нужные флаги в docker run для рендеринга Webots.
|
||||
def _detect_gpu() -> str:
|
||||
"""Return "nvidia", "mesa", or "software" based on what GPU drivers are available.
|
||||
"""Return "nvidia", "mesa", or "software" based on available GPU drivers.
|
||||
Возвращает "nvidia", "mesa" или "software" в зависимости от доступных драйверов GPU.
|
||||
"""
|
||||
if shutil.which("nvidia-smi"):
|
||||
@@ -130,11 +88,9 @@ def _detect_gpu() -> str:
|
||||
return "software"
|
||||
|
||||
|
||||
# List all Docker images currently available on this machine.
|
||||
# Получаем список всех Docker-образов доступных на этой машине.
|
||||
def _docker_images() -> set:
|
||||
"""Return the set of "repository:tag" strings for all locally available Docker images.
|
||||
Возвращает множество строк "репозиторий:тег" для всех локально доступных Docker-образов.
|
||||
"""Return the set of "repository:tag" strings for all local Docker images.
|
||||
Возвращает множество строк "репозиторий:тег" для всех локальных Docker-образов.
|
||||
"""
|
||||
r = subprocess.run(
|
||||
["docker", "images", "--format", "{{.Repository}}:{{.Tag}}"],
|
||||
@@ -143,11 +99,9 @@ def _docker_images() -> set:
|
||||
return set(r.stdout.strip().splitlines())
|
||||
|
||||
|
||||
# Return the first image from the candidates list that is already present locally.
|
||||
# Возвращаем первый образ из списка кандидатов, который уже присутствует локально.
|
||||
def _find_image(candidates: List[str]) -> Optional[str]:
|
||||
"""Return the first candidate image that exists locally, or None if none are available.
|
||||
Возвращает первый образ-кандидат, присутствующий локально, или None если ни один не найден.
|
||||
"""Return the first candidate image that exists locally, or None.
|
||||
Возвращает первый образ-кандидат, присутствующий локально, или None.
|
||||
"""
|
||||
available = _docker_images()
|
||||
for img in candidates:
|
||||
@@ -156,82 +110,13 @@ def _find_image(candidates: List[str]) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
# Build the ROS2 project locally with colcon. Used when launching in local mode
|
||||
# and the install/ directory does not exist yet.
|
||||
# Собираем ROS2-проект локально с помощью colcon. Используется при запуске в локальном режиме,
|
||||
# если директория install/ ещё не существует.
|
||||
def _task_build(screen: LogScreen) -> None:
|
||||
"""Worker function that runs inside LogScreen. Counts packages, then runs colcon build
|
||||
with release mixin and updates progress as each package finishes.
|
||||
Рабочая функция внутри LogScreen. Подсчитывает пакеты, запускает colcon build с mixin release
|
||||
и обновляет прогресс по мере завершения каждого пакета.
|
||||
"""
|
||||
try:
|
||||
screen.write("[bold]Building project with colcon[/bold]\n")
|
||||
|
||||
# Count packages first so we can show X/total progress.
|
||||
# Сначала считаем пакеты, чтобы показывать X/всего в прогрессе.
|
||||
list_proc = subprocess.run(
|
||||
["bash", "-c", f"source {_JAZZY_DIR}/setup.bash && colcon list"],
|
||||
capture_output=True, text=True, cwd=_PROJECT_DIR,
|
||||
)
|
||||
total = max(len([l for l in list_proc.stdout.splitlines() if l.strip()]), 1)
|
||||
screen.set_progress(0, f"0 / {total} packages done")
|
||||
built = 0
|
||||
|
||||
proc = subprocess.Popen(
|
||||
["bash", "-c",
|
||||
f"source {_JAZZY_DIR}/setup.bash && colcon build --mixin release"],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
text=True, cwd=_PROJECT_DIR,
|
||||
)
|
||||
for line in proc.stdout:
|
||||
s = line.rstrip()
|
||||
if s:
|
||||
screen.write(s)
|
||||
# colcon prints "Finished <<<" or "Failed <<<" when each package is done.
|
||||
# colcon печатает "Finished <<<" или "Failed <<<" когда каждый пакет готов.
|
||||
if "Finished <<<" in line or "Failed <<<" in line:
|
||||
built += 1
|
||||
screen.set_progress(built / total * 100, f"{built} / {total} packages done")
|
||||
proc.wait()
|
||||
|
||||
if proc.returncode != 0:
|
||||
screen.write("\n[red]Build failed.[/red]")
|
||||
screen.finish(False)
|
||||
return
|
||||
|
||||
screen.set_progress(100, "Build complete")
|
||||
screen.write("\n[green]Build successful.[/green]")
|
||||
screen.finish(True)
|
||||
except Exception as exc:
|
||||
screen.write(f"\n[red]Error:[/red] {exc}")
|
||||
screen.finish(False)
|
||||
|
||||
|
||||
class _BuildApp(App[bool]):
|
||||
"""Minimal app that opens a LogScreen running _task_build and exits with the build result.
|
||||
Минимальное приложение, открывающее LogScreen с _task_build и завершающееся с результатом сборки.
|
||||
"""
|
||||
|
||||
CSS = SCREEN_CSS
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.push_screen(
|
||||
LogScreen("Building project before launch", _task_build, show_progress=True),
|
||||
self.exit,
|
||||
)
|
||||
|
||||
|
||||
# Start the ROS2 launch file directly on this machine without Docker.
|
||||
# Uses start_new_session so we can kill the whole process group with one signal.
|
||||
# Запускаем launch-файл ROS2 напрямую на этой машине без Docker.
|
||||
# Используем start_new_session, чтобы можно было убить всю группу процессов одним сигналом.
|
||||
def _task_run_local(screen: RunScreen, mode: str) -> None:
|
||||
"""Worker function that runs inside RunScreen. Launches iiwa.launch.py locally by sourcing
|
||||
ROS2 and install/setup.bash, then streams output until the process exits or is stopped.
|
||||
Рабочая функция внутри RunScreen. Запускает iiwa.launch.py локально через source ROS2 и
|
||||
install/setup.bash, затем транслирует вывод до завершения процесса или его остановки.
|
||||
# Local (non-Docker) launch
|
||||
# Локальный (не Docker) запуск
|
||||
def _run_local(mode: str) -> None:
|
||||
"""Launch iiwa.launch.py natively. The whole ros2 launch tree runs in its own
|
||||
session so a single Ctrl-C tears down every node cleanly.
|
||||
Запускает iiwa.launch.py нативно. Всё дерево ros2 launch работает в своей сессии,
|
||||
поэтому один Ctrl-C аккуратно завершает каждый узел.
|
||||
"""
|
||||
config = str(_CONFIG_PATH)
|
||||
ros_cmd = f"ros2 launch iiwa_bringup iiwa.launch.py setting:={config}"
|
||||
@@ -248,51 +133,28 @@ def _task_run_local(screen: RunScreen, mode: str) -> None:
|
||||
f"{ros_cmd}"
|
||||
)
|
||||
|
||||
label = "Webots simulator" if mode == "webots" else "Controller"
|
||||
screen.write(f"[bold]Launching {label} (local)[/bold]")
|
||||
screen.write(f"[dim]{ros_cmd}[/dim]")
|
||||
label = "симулятор Webots" if mode == "webots" else "контроллер"
|
||||
header(f"Запуск: {label} (локально)")
|
||||
ui.note(ros_cmd)
|
||||
if webots_home:
|
||||
screen.write(f"[dim]WEBOTS_HOME: {webots_home}[/dim]")
|
||||
screen.write("")
|
||||
ui.note(f"WEBOTS_HOME: {webots_home}")
|
||||
ui.note("Нажмите Ctrl-C чтобы остановить")
|
||||
|
||||
proc = subprocess.Popen(
|
||||
rc = process.stream(
|
||||
["bash", "-c", full_cmd],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
text=True, cwd=_PROJECT_DIR,
|
||||
start_new_session=True,
|
||||
cwd=str(_PROJECT_DIR),
|
||||
new_session=True,
|
||||
)
|
||||
screen.set_proc(proc)
|
||||
# Kill the entire session so all ROS2 nodes are terminated together.
|
||||
# ros2 launch puts each node in its own process group (setpgrp), so killpg on the
|
||||
# bash pgid only reaches bash/launch itself. All nodes share the session started
|
||||
# with start_new_session=True, so pkill -s reaches every one of them.
|
||||
# Убиваем всю сессию, чтобы все ROS2-узлы завершились вместе.
|
||||
# ros2 launch помещает каждый узел в отдельную группу процессов (setpgrp), поэтому
|
||||
# killpg по pgid bash достигает только bash/launch. Все узлы разделяют сессию,
|
||||
# созданную через start_new_session=True, поэтому pkill -s достигает каждого из них.
|
||||
sid = os.getsid(proc.pid)
|
||||
screen.set_kill_fn(lambda: subprocess.run(
|
||||
["pkill", "-TERM", "-s", str(sid)], capture_output=True
|
||||
))
|
||||
|
||||
for line in proc.stdout:
|
||||
s = line.rstrip()
|
||||
if s:
|
||||
screen.write(s)
|
||||
|
||||
proc.wait()
|
||||
screen.finish(stopped=screen._stopped)
|
||||
done(rc in (0, -2, -15, 130), "Остановлено")
|
||||
|
||||
|
||||
# Start the ROS2 launch file inside a Docker container.
|
||||
# For Webots mode we also forward X11 and GPU access so the simulator window can appear on screen.
|
||||
# Запускаем launch-файл ROS2 внутри Docker-контейнера.
|
||||
# Для режима Webots также пробрасываем X11 и доступ к GPU, чтобы окно симулятора появилось на экране.
|
||||
def _task_run_docker(screen: RunScreen, image: str, mode: str, gpu: str) -> None:
|
||||
"""Worker function that runs inside RunScreen. Builds the docker run command with the
|
||||
appropriate GPU/X11 flags for Webots, then streams container output until stopped or exited.
|
||||
Рабочая функция внутри RunScreen. Формирует команду docker run с нужными флагами GPU/X11
|
||||
для Webots, затем транслирует вывод контейнера до остановки или завершения.
|
||||
# Docker launch
|
||||
# Запуск в Docker
|
||||
def _run_docker(image: str, mode: str, gpu: str) -> None:
|
||||
"""Launch iiwa.launch.py inside a Docker container, forwarding X11/GPU for Webots.
|
||||
The container is stopped with ``docker kill`` on Ctrl-C.
|
||||
Запускает iiwa.launch.py внутри Docker-контейнера, пробрасывая X11/GPU для Webots.
|
||||
Контейнер останавливается через ``docker kill`` по Ctrl-C.
|
||||
"""
|
||||
container = _CONTAINER_WEBOTS if mode == "webots" else _CONTAINER_CONTROLLER
|
||||
|
||||
@@ -303,8 +165,8 @@ def _task_run_docker(screen: RunScreen, image: str, mode: str, gpu: str) -> None
|
||||
if mode == "webots":
|
||||
ros_cmd += " simulate:=1"
|
||||
|
||||
# Remove any stale container with the same name left from a previous run.
|
||||
# Удаляем устаревший контейнер с таким же именем, оставшийся от предыдущего запуска.
|
||||
# Remove any stale container with the same name from a previous run.
|
||||
# Удаляем устаревший контейнер с таким же именем от предыдущего запуска.
|
||||
subprocess.run(["docker", "rm", "-f", container], capture_output=True)
|
||||
|
||||
cmd = [
|
||||
@@ -316,15 +178,11 @@ def _task_run_docker(screen: RunScreen, image: str, mode: str, gpu: str) -> None
|
||||
]
|
||||
|
||||
if mode == "webots":
|
||||
# Allow the container to open windows on the host display.
|
||||
# Разрешаем контейнеру открывать окна на дисплее хоста.
|
||||
subprocess.run(["xhost", "+local:docker"], capture_output=True)
|
||||
cmd += [
|
||||
"-e", f"DISPLAY={os.environ.get('DISPLAY', ':0')}",
|
||||
"-e", "QT_X11_NO_MITSHM=1",
|
||||
"-v", "/tmp/.X11-unix:/tmp/.X11-unix:rw",
|
||||
# Persist the Webots asset cache so it is not re-downloaded on every launch.
|
||||
# Сохраняем кэш ассетов Webots, чтобы он не скачивался заново при каждом запуске.
|
||||
"-v", f"{_WEBOTS_CACHE_VOLUME}:/root/.cache/Cyberbotics/Webots",
|
||||
]
|
||||
if gpu == "nvidia":
|
||||
@@ -334,19 +192,13 @@ def _task_run_docker(screen: RunScreen, image: str, mode: str, gpu: str) -> None
|
||||
"-e", "NVIDIA_DRIVER_CAPABILITIES=graphics,utility,compute",
|
||||
]
|
||||
elif gpu == "mesa":
|
||||
# Pass through the DRI device for Intel/AMD hardware acceleration.
|
||||
# Пробрасываем DRI-устройство для аппаратного ускорения Intel/AMD.
|
||||
cmd += ["--device", "/dev/dri"]
|
||||
else:
|
||||
# No GPU found - fall back to software rendering via llvmpipe.
|
||||
# GPU не найден - используем программный рендеринг через llvmpipe.
|
||||
cmd += [
|
||||
"-e", "LIBGL_ALWAYS_SOFTWARE=1",
|
||||
"-e", "GALLIUM_DRIVER=llvmpipe",
|
||||
]
|
||||
|
||||
# Mount the config file so the container uses our local cobot-setting.yaml.
|
||||
# Монтируем конфиг-файл, чтобы контейнер использовал наш локальный cobot-setting.yaml.
|
||||
if _CONFIG_PATH.exists():
|
||||
cmd += ["-v", f"{_CONFIG_PATH}:{_CONFIG_IN_CONTAINER}:ro"]
|
||||
|
||||
@@ -355,170 +207,87 @@ def _task_run_docker(screen: RunScreen, image: str, mode: str, gpu: str) -> None
|
||||
_GPU_LABELS = {
|
||||
"nvidia": "NVIDIA GPU",
|
||||
"mesa": "Intel/AMD DRI (Mesa)",
|
||||
"software": "Software rendering (llvmpipe)",
|
||||
"software": "Программный рендеринг (llvmpipe)",
|
||||
}
|
||||
label = "Webots simulator" if mode == "webots" else "Controller"
|
||||
screen.write(f"[bold]Launching {label} in Docker[/bold]")
|
||||
screen.write(f"[dim]Image: {image}[/dim]")
|
||||
label = "симулятор Webots" if mode == "webots" else "контроллер"
|
||||
header(f"Запуск: {label} в Docker")
|
||||
ui.note(f"Образ: {image}")
|
||||
if mode == "webots":
|
||||
screen.write(f"[dim]GPU: {_GPU_LABELS.get(gpu, gpu)}[/dim]")
|
||||
screen.write("")
|
||||
ui.note(f"GPU: {_GPU_LABELS.get(gpu, gpu)}")
|
||||
ui.note("Нажмите Ctrl-C чтобы остановить")
|
||||
|
||||
proc = subprocess.Popen(
|
||||
rc = process.stream(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
kill_fn=lambda: subprocess.run(["docker", "kill", container], capture_output=True),
|
||||
)
|
||||
screen.set_proc(proc)
|
||||
# Use docker kill instead of proc.terminate() so the container is stopped immediately.
|
||||
# Terminating only the docker CLI process leaves the container itself running.
|
||||
# Используем docker kill вместо proc.terminate(), чтобы контейнер остановился немедленно.
|
||||
# Завершение только процесса docker CLI оставляет сам контейнер работающим.
|
||||
screen.set_kill_fn(lambda: subprocess.run(["docker", "kill", container], capture_output=True))
|
||||
|
||||
for line in proc.stdout:
|
||||
s = line.rstrip()
|
||||
if s:
|
||||
screen.write(s)
|
||||
|
||||
proc.wait()
|
||||
screen.finish(stopped=screen._stopped)
|
||||
done(rc in (0, -2, -15, 130), "Остановлено")
|
||||
|
||||
|
||||
# Wraps a RunScreen in an App so it can be launched with .run().
|
||||
# Оборачивает RunScreen в App, чтобы его можно было запустить через .run().
|
||||
class _RunApp(App[None]):
|
||||
"""Minimal app that wraps a RunScreen so it can be started with .run().
|
||||
Минимальное приложение, оборачивающее RunScreen чтобы его можно было запустить через .run().
|
||||
"""
|
||||
CSS = SCREEN_CSS
|
||||
|
||||
def __init__(self, title: str, task: Callable):
|
||||
super().__init__()
|
||||
self._title = title
|
||||
self._run_fn = task
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.push_screen(RunScreen(self._title, self._run_fn), lambda _: self.exit())
|
||||
|
||||
|
||||
# Guide the user through launching locally - asks what to run, checks prerequisites,
|
||||
# installs Webots and builds the project if needed, then launches.
|
||||
# Ведёт пользователя через локальный запуск - спрашивает что запустить, проверяет
|
||||
# предварительные условия, устанавливает Webots и собирает проект при необходимости, затем запускает.
|
||||
def _local_flow(args: argparse.Namespace) -> None:
|
||||
"""Interactive flow for local (non-Docker) launch. Checks Webots, ROS2, and build state,
|
||||
offers to install/build missing pieces, then starts RunScreen.
|
||||
Интерактивный сценарий для локального (не Docker) запуска. Проверяет Webots, ROS2 и состояние
|
||||
сборки, предлагает установить/собрать недостающее, затем запускает RunScreen.
|
||||
"""Interactive flow for local launch: check Webots/ROS2/build, then run.
|
||||
Интерактивный сценарий локального запуска: проверка Webots/ROS2/сборки, затем запуск.
|
||||
"""
|
||||
mode_v = _ask(
|
||||
"Run local",
|
||||
"What do you want to launch?",
|
||||
["Controller", "Webots simulator"],
|
||||
"Controller",
|
||||
mode_v = ui.select(
|
||||
"Что запустить?",
|
||||
["Контроллер", "Симулятор Webots"],
|
||||
"Контроллер",
|
||||
)
|
||||
if mode_v is None:
|
||||
return
|
||||
mode = "webots" if mode_v == "Webots simulator" else "controller"
|
||||
mode = "webots" if mode_v == "Симулятор Webots" else "controller"
|
||||
|
||||
# Check Webots installed (local mode only)
|
||||
if mode == "webots" and not webots_installed():
|
||||
v = _ask(
|
||||
"Webots not found",
|
||||
f"Webots {_WEBOTS_VERSION} is not installed. Install it now?",
|
||||
[f"Yes, install Webots {_WEBOTS_VERSION}", "No, cancel"],
|
||||
f"Yes, install Webots {_WEBOTS_VERSION}",
|
||||
)
|
||||
if v is None or v.startswith("No"):
|
||||
if not ui.confirm(f"Webots {_WEBOTS_VERSION} не установлен. Установить сейчас?",
|
||||
default=True):
|
||||
return
|
||||
ok = WebotsInstallApp().run()
|
||||
if not ok:
|
||||
if not privilege.ensure_sudo() or not install_webots():
|
||||
return
|
||||
|
||||
# Check ROS2 Jazzy
|
||||
if not _JAZZY_DIR.is_dir():
|
||||
v = _ask(
|
||||
"ROS2 not found",
|
||||
"ROS2 Jazzy is not installed. Run local-setup now?",
|
||||
["Yes, run local-setup", "No, cancel"],
|
||||
"Yes, run local-setup",
|
||||
)
|
||||
if v and v.startswith("Yes"):
|
||||
if ui.confirm("ROS2 Jazzy не установлен. Запустить local-setup?", default=True):
|
||||
from cobot.commands.local_setup import run as _local_setup
|
||||
_local_setup(args)
|
||||
return
|
||||
|
||||
# Check project built
|
||||
if not (_INSTALL_DIR / "setup.bash").exists():
|
||||
v = _ask(
|
||||
"Project not built",
|
||||
"The project has not been built yet. Build it now?",
|
||||
["Yes, build now", "No, cancel"],
|
||||
"Yes, build now",
|
||||
)
|
||||
if v is None or v.startswith("No"):
|
||||
if not ui.confirm("Проект ещё не собран. Собрать сейчас?", default=True):
|
||||
return
|
||||
ok = _BuildApp().run()
|
||||
if not ok:
|
||||
if not privilege.ensure_sudo() or not build_workspace():
|
||||
return
|
||||
|
||||
label = "Webots simulator" if mode == "webots" else "Controller"
|
||||
_RunApp(f"Running {label} — local", lambda s: _task_run_local(s, mode)).run()
|
||||
_run_local(mode)
|
||||
|
||||
|
||||
# Guide the user through launching in Docker - asks what to run, finds a suitable image,
|
||||
# detects the GPU for Webots, and launches.
|
||||
# Ведёт пользователя через запуск в Docker - спрашивает что запустить, ищет подходящий образ,
|
||||
# определяет GPU для Webots и запускает.
|
||||
def _docker_flow(args: argparse.Namespace) -> None:
|
||||
"""Interactive flow for Docker launch. Finds the best available image, detects the GPU
|
||||
for Webots mode, then starts RunScreen with the docker run task.
|
||||
Интерактивный сценарий для запуска в Docker. Находит лучший доступный образ, определяет GPU
|
||||
для режима Webots, затем запускает RunScreen с задачей docker run.
|
||||
"""Interactive flow for Docker launch: pick an image, detect GPU, then run.
|
||||
Интерактивный сценарий запуска в Docker: выбор образа, определение GPU, затем запуск.
|
||||
"""
|
||||
if not shutil.which("docker"):
|
||||
from rich.console import Console
|
||||
Console().print("[red]Error:[/red] Docker is not installed or not on PATH.")
|
||||
ui.error("Docker не установлен или отсутствует в PATH.")
|
||||
return
|
||||
|
||||
mode_v = _ask(
|
||||
"Run in Docker",
|
||||
"What do you want to launch?",
|
||||
["Controller", "Webots simulator"],
|
||||
"Controller",
|
||||
mode_v = ui.select(
|
||||
"Что запустить?",
|
||||
["Контроллер", "Симулятор Webots"],
|
||||
"Контроллер",
|
||||
)
|
||||
if mode_v is None:
|
||||
return
|
||||
mode = "webots" if mode_v == "Webots simulator" else "controller"
|
||||
mode = "webots" if mode_v == "Симулятор Webots" else "controller"
|
||||
|
||||
candidates = _WEBOTS_IMAGES if mode == "webots" else _CONTROLLER_IMAGES
|
||||
image = _find_image(candidates)
|
||||
|
||||
if image is None:
|
||||
# No image available - offer to run docker-setup to get one.
|
||||
# Образ не найден - предлагаем запустить docker-setup чтобы его получить.
|
||||
what = "Webots" if mode == "webots" else "controller or Webots"
|
||||
v = _ask(
|
||||
"No image found",
|
||||
f"No Docker image found for {what}. Run docker-setup now?",
|
||||
["Yes, run docker-setup", "No, cancel"],
|
||||
"Yes, run docker-setup",
|
||||
)
|
||||
if v and v.startswith("Yes"):
|
||||
what = "Webots" if mode == "webots" else "контроллера или Webots"
|
||||
if ui.confirm(f"Docker-образ для {what} не найден. Запустить docker-setup?",
|
||||
default=True):
|
||||
from cobot.commands.docker_setup import run as _docker_setup
|
||||
_docker_setup(args)
|
||||
return
|
||||
|
||||
# Only detect GPU for Webots - the controller does not need a display.
|
||||
# GPU определяем только для Webots - контроллеру дисплей не нужен.
|
||||
gpu = _detect_gpu() if mode == "webots" else "software"
|
||||
|
||||
label = "Webots simulator" if mode == "webots" else "Controller"
|
||||
_RunApp(
|
||||
f"Running {label} — Docker",
|
||||
lambda s: _task_run_docker(s, image, mode, gpu),
|
||||
).run()
|
||||
_run_docker(image, mode, gpu)
|
||||
|
||||
|
||||
def register(subparsers: argparse._SubParsersAction) -> None:
|
||||
@@ -544,17 +313,14 @@ def run(args: argparse.Namespace) -> None:
|
||||
elif mode == "docker":
|
||||
_docker_flow(args)
|
||||
else:
|
||||
# No mode given - ask the user how they want to run.
|
||||
# Режим не указан - спрашиваем пользователя как он хочет запустить.
|
||||
v = _ask(
|
||||
"Run",
|
||||
"How do you want to run the project?",
|
||||
["Local (native ROS2)", "Docker"],
|
||||
"Local (native ROS2)",
|
||||
v = ui.select(
|
||||
"Как запустить проект?",
|
||||
["Локально (нативный ROS2)", "Docker"],
|
||||
"Локально (нативный ROS2)",
|
||||
)
|
||||
if v is None:
|
||||
return
|
||||
if v.startswith("Local"):
|
||||
if v.startswith("Локально"):
|
||||
_local_flow(args)
|
||||
else:
|
||||
_docker_flow(args)
|
||||
|
||||
+14
-59
@@ -1,7 +1,6 @@
|
||||
import argparse
|
||||
from typing import List, Optional
|
||||
|
||||
from textual.app import App
|
||||
from cobot import ui
|
||||
|
||||
# Import each sub-command's run() so we can call them in sequence.
|
||||
# Импортируем run() каждой подкоманды, чтобы вызывать их по порядку.
|
||||
@@ -9,40 +8,6 @@ from cobot.commands.doc_setup import run as _doc_setup
|
||||
from cobot.commands.docker_setup import run as _docker_setup
|
||||
from cobot.commands.local_setup import run as _local_setup
|
||||
from cobot.commands.robot_setup import run as _robot_setup
|
||||
from cobot.tui import SCREEN_CSS, PickScreen
|
||||
|
||||
|
||||
# A minimal Textual app that asks a single question and exits with the chosen value.
|
||||
# We need this because Textual screens cannot run outside of an App context.
|
||||
# Минимальное Textual-приложение, которое задаёт один вопрос и выходит с выбранным значением.
|
||||
# Нам это нужно, потому что экраны Textual не могут работать вне контекста приложения.
|
||||
class _Ask(App[Optional[str]]):
|
||||
"""Minimal one-question Textual app. Pushes a PickScreen and exits with the chosen value.
|
||||
Минимальное однвопросное Textual-приложение. Открывает PickScreen и завершается с выбранным значением.
|
||||
"""
|
||||
CSS = SCREEN_CSS
|
||||
|
||||
def __init__(self, step: str, question: str, options: List[str], default: str):
|
||||
super().__init__()
|
||||
self._step = step
|
||||
self._question = question
|
||||
self._options = options
|
||||
self._default = default
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.push_screen(
|
||||
PickScreen(self._step, self._question, self._options, self._default),
|
||||
self.exit,
|
||||
)
|
||||
|
||||
|
||||
def _ask(step: str, question: str, options: List[str], default: str) -> Optional[str]:
|
||||
"""Show a PickScreen and return the selected value, or None if the user pressed Escape.
|
||||
Показывает PickScreen и возвращает выбранное значение или None если пользователь нажал Escape.
|
||||
"""
|
||||
# Returns None if the user pressed Escape to cancel the whole wizard.
|
||||
# Возвращает None если пользователь нажал Escape для отмены всего мастера.
|
||||
return _Ask(step, question, options, default).run()
|
||||
|
||||
|
||||
def register(subparsers):
|
||||
@@ -54,44 +19,34 @@ def register(subparsers):
|
||||
|
||||
|
||||
def run(args: argparse.Namespace) -> None:
|
||||
"""Run the three-step first-time setup wizard: doc server -> build env -> robot config.
|
||||
Запускает трёхшаговый мастер первоначальной настройки: сервер документации -> среда сборки -> конфиг.
|
||||
"""Run the three-step first-time setup wizard: docs -> build env -> robot config.
|
||||
Запускает трёхшаговый мастер первичной настройки: документация -> среда сборки -> конфиг.
|
||||
"""
|
||||
ui.header("Первичная настройка", "3 шага")
|
||||
|
||||
# Step 1 - documentation server.
|
||||
# Шаг 1 - сервер документации.
|
||||
v = _ask("Step 1 of 3", "Set up the documentation server?", ["Yes", "No"], "Yes")
|
||||
if v is None:
|
||||
return
|
||||
if v == "Yes":
|
||||
if ui.confirm("Шаг 1/3 — настроить сервер документации?", default=True):
|
||||
_doc_setup(args)
|
||||
|
||||
# Step 2 - build environment: local ROS2 or Docker.
|
||||
# Шаг 2 - среда сборки: локальный ROS2 или Docker.
|
||||
v = _ask(
|
||||
"Step 2 of 3",
|
||||
"How do you want to set up the build environment?",
|
||||
env_choice = ui.select(
|
||||
"Шаг 2/3 — как настроить среду сборки?",
|
||||
[
|
||||
"local-setup — install ROS2 Jazzy on this machine and build with colcon",
|
||||
"docker-setup — build a Docker image with ROS2 Jazzy pre-installed",
|
||||
"local-setup — установить ROS2 Jazzy на эту машину и собрать через colcon",
|
||||
"docker-setup — собрать Docker-образ с предустановленным ROS2 Jazzy",
|
||||
],
|
||||
"local-setup — install ROS2 Jazzy on this machine and build with colcon",
|
||||
"local-setup — установить ROS2 Jazzy на эту машину и собрать через colcon",
|
||||
)
|
||||
if v is None:
|
||||
if env_choice is None:
|
||||
return
|
||||
if v.startswith("local"):
|
||||
if env_choice.startswith("local"):
|
||||
_local_setup(args)
|
||||
else:
|
||||
_docker_setup(args)
|
||||
|
||||
# Step 3 - robot parameters in cobot-setting.yaml.
|
||||
# Шаг 3 - параметры робота в cobot-setting.yaml.
|
||||
v = _ask(
|
||||
"Step 3 of 3",
|
||||
"Configure robot parameters (cobot-setting.yaml)?",
|
||||
["Yes", "No"],
|
||||
"Yes",
|
||||
)
|
||||
if v is None:
|
||||
return
|
||||
if v == "Yes":
|
||||
if ui.confirm("Шаг 3/3 — настроить параметры робота (cobot-setting.yaml)?", default=True):
|
||||
_robot_setup(args)
|
||||
|
||||
+48
-112
@@ -4,134 +4,70 @@ import argparse
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from textual.app import App
|
||||
|
||||
from cobot.tui import SCREEN_CSS, LogScreen
|
||||
from cobot import process, ui
|
||||
from cobot.ui import done
|
||||
from cobot.process import StepProgress
|
||||
|
||||
_PROJECT_DIR = Path(__file__).parent.parent.parent
|
||||
|
||||
|
||||
# Pull the latest commits from the remote and reinstall the cobot CLI in one go.
|
||||
# Progress bar: fetch (0-30%), pull (30-80%), reinstall (80-100%).
|
||||
# Скачиваем последние коммиты с удалённого репозитория и переустанавливаем cobot CLI за один раз.
|
||||
# Прогресс-бар: fetch (0-30%), pull (30-80%), переустановка (80-100%).
|
||||
def _task_update(screen: LogScreen) -> None:
|
||||
"""Worker function that runs inside LogScreen. Fetches the current branch, shows incoming
|
||||
commits, pulls changes, then reinstalls the cobot CLI via uv tool install --editable.
|
||||
Рабочая функция, выполняемая внутри LogScreen. Получает текущую ветку, показывает входящие
|
||||
коммиты, вытягивает изменения, затем переустанавливает cobot CLI через uv tool install --editable.
|
||||
def _git(*args: str) -> str:
|
||||
"""Run a git command in the project dir and return its stripped stdout.
|
||||
Запускает git-команду в директории проекта и возвращает обрезанный stdout.
|
||||
"""
|
||||
return subprocess.check_output(["git", *args], cwd=_PROJECT_DIR, text=True).strip()
|
||||
|
||||
|
||||
def _update() -> None:
|
||||
"""Fetch the current branch, show incoming commits, pull, and reinstall the cobot CLI.
|
||||
Progress: fetch (0-30 %), pull (30-80 %), reinstall (80-100 %).
|
||||
Получает текущую ветку, показывает входящие коммиты, делает pull и переустанавливает CLI.
|
||||
Прогресс: fetch (0-30 %), pull (30-80 %), переустановка (80-100 %).
|
||||
"""
|
||||
ok, fail_msg = True, ""
|
||||
with StepProgress("Обновление проекта") as p:
|
||||
try:
|
||||
# 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]")
|
||||
branch = _git("rev-parse", "--abbrev-ref", "HEAD")
|
||||
p.raw(f"[cyan]▸[/cyan] Ветка: [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():
|
||||
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
|
||||
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()
|
||||
p.set(30)
|
||||
|
||||
behind = _git("rev-list", f"HEAD..origin/{branch}", "--count")
|
||||
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)
|
||||
p.set(100, "Уже актуально")
|
||||
done(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]")
|
||||
p.raw(f"\n[bold]{behind} новых коммит(ов):[/bold]")
|
||||
for line in _git("log", f"HEAD..origin/{branch}", "--oneline").splitlines():
|
||||
p.log(line)
|
||||
|
||||
# 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():
|
||||
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
|
||||
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)
|
||||
p.set(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")
|
||||
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:
|
||||
screen.write(f"[yellow]Warning:[/yellow] reinstall failed — {reinstall_err.strip()}")
|
||||
p.raw("[yellow]Предупреждение:[/yellow] переустановка не удалась")
|
||||
p.set(100, "Готово")
|
||||
except subprocess.CalledProcessError as exc:
|
||||
ok, fail_msg = False, str(exc)
|
||||
|
||||
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]):
|
||||
"""Minimal Textual app that opens a LogScreen running _task_update and exits when it closes.
|
||||
Минимальное Textual-приложение, открывающее LogScreen с _task_update и завершающееся при закрытии.
|
||||
"""
|
||||
|
||||
CSS = SCREEN_CSS
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.push_screen(
|
||||
LogScreen("Updating project", _task_update, show_progress=True),
|
||||
lambda _: self.exit(),
|
||||
)
|
||||
done(ok, "Проект обновлён" if ok else fail_msg)
|
||||
|
||||
|
||||
def register(subparsers: argparse._SubParsersAction) -> None:
|
||||
@@ -140,4 +76,4 @@ def register(subparsers: argparse._SubParsersAction) -> None:
|
||||
|
||||
|
||||
def run(args: argparse.Namespace) -> None:
|
||||
_UpdateApp().run()
|
||||
_update()
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,332 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import threading
|
||||
from typing import Callable, Dict, List, Optional, Sequence
|
||||
|
||||
from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn
|
||||
|
||||
from cobot.ui import console, done, header
|
||||
|
||||
# Type of an optional callback invoked for every streamed output line.
|
||||
# Тип опционального колбэка, вызываемого для каждой строки потокового вывода.
|
||||
LineHook = Callable[[str], None]
|
||||
|
||||
# Registry of all live subprocesses, so the signal handler can kill them on exit.
|
||||
# Maps pid -> Popen. Guarded by a lock because procs start/finish in helper calls.
|
||||
# Реестр всех живых подпроцессов, чтобы обработчик сигнала мог убить их при выходе.
|
||||
# Сопоставляет pid -> Popen. Защищён блокировкой, т.к. процессы создаются/завершаются в хелперах.
|
||||
_procs: Dict[int, subprocess.Popen] = {}
|
||||
_procs_lock = threading.Lock()
|
||||
_handlers_installed = False
|
||||
|
||||
|
||||
def _register(proc: subprocess.Popen) -> None:
|
||||
with _procs_lock:
|
||||
_procs[proc.pid] = proc
|
||||
|
||||
|
||||
def _unregister(proc: subprocess.Popen) -> None:
|
||||
with _procs_lock:
|
||||
_procs.pop(proc.pid, None)
|
||||
|
||||
|
||||
def _kill_proc(proc: subprocess.Popen) -> None:
|
||||
"""Terminate a process and everything it spawned.
|
||||
|
||||
Three strategies, in order of how the process was started:
|
||||
* a custom kill_fn (e.g. ``docker kill <container>``) registered on the proc;
|
||||
* a new-session process (e.g. ros2 launch) — every node shares the session, so
|
||||
``pkill -s <sid>`` reaches all of them (killpg would only hit the launcher);
|
||||
* otherwise the process group (SIGTERM then SIGKILL), or the bare process.
|
||||
|
||||
Завершает процесс и всё, что он породил. Три стратегии по способу запуска:
|
||||
пользовательский kill_fn (например ``docker kill``); процесс в новой сессии
|
||||
(ros2 launch — все узлы делят сессию, поэтому ``pkill -s`` достаёт каждый);
|
||||
иначе группа процессов (SIGTERM→SIGKILL) или сам процесс.
|
||||
"""
|
||||
if proc.poll() is not None:
|
||||
return
|
||||
|
||||
kill_fn = getattr(proc, "_cobot_kill_fn", None)
|
||||
if kill_fn is not None:
|
||||
try:
|
||||
kill_fn()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
return
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if getattr(proc, "_cobot_new_session", False):
|
||||
try:
|
||||
sid = os.getsid(proc.pid)
|
||||
subprocess.run(["pkill", "-TERM", "-s", str(sid)],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
try:
|
||||
proc.wait(timeout=3)
|
||||
return
|
||||
except subprocess.TimeoutExpired:
|
||||
subprocess.run(["pkill", "-KILL", "-s", str(sid)],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
pgid = os.getpgid(proc.pid)
|
||||
os.killpg(pgid, signal.SIGTERM)
|
||||
try:
|
||||
proc.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(pgid, signal.SIGKILL)
|
||||
except Exception:
|
||||
try:
|
||||
proc.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def kill_all() -> None:
|
||||
"""Kill every registered subprocess. Used by the signal handler and atexit.
|
||||
Убивает каждый зарегистрированный подпроцесс. Используется обработчиком сигнала и atexit.
|
||||
"""
|
||||
with _procs_lock:
|
||||
procs = list(_procs.values())
|
||||
for proc in procs:
|
||||
_kill_proc(proc)
|
||||
|
||||
|
||||
def _on_sigint(signum, frame): # noqa: ANN001
|
||||
"""SIGINT handler: stop all children, print a cancel note, and exit non-zero.
|
||||
Обработчик SIGINT: останавливает всех потомков, печатает заметку об отмене и выходит с ненулём.
|
||||
"""
|
||||
kill_all()
|
||||
console.print("\n[yellow]Прервано пользователем (Ctrl-C).[/yellow]")
|
||||
raise SystemExit(130)
|
||||
|
||||
|
||||
def install_signal_handlers() -> None:
|
||||
"""Install the SIGINT handler and atexit cleanup exactly once.
|
||||
Устанавливает обработчик SIGINT и очистку atexit ровно один раз.
|
||||
"""
|
||||
global _handlers_installed
|
||||
if _handlers_installed:
|
||||
return
|
||||
_handlers_installed = True
|
||||
signal.signal(signal.SIGINT, _on_sigint)
|
||||
atexit.register(kill_all)
|
||||
|
||||
|
||||
def spawn(
|
||||
cmd: Sequence[str],
|
||||
*,
|
||||
env: Optional[dict] = None,
|
||||
cwd: Optional[str] = None,
|
||||
new_session: bool = False,
|
||||
shell: bool = False,
|
||||
kill_fn: Optional[Callable] = None,
|
||||
) -> subprocess.Popen:
|
||||
"""Start a subprocess with merged stdout/stderr as text, register it, and return it.
|
||||
|
||||
new_session=True puts the process in its own session/process-group so the whole
|
||||
tree (e.g. all ros2 launch nodes) can be torn down with one signal. kill_fn is an
|
||||
optional custom teardown (e.g. ``docker kill``) used by the cleanup logic.
|
||||
|
||||
Запускает подпроцесс с объединённым stdout/stderr в текстовом режиме, регистрирует
|
||||
его и возвращает. new_session=True помещает процесс в собственную сессию/группу.
|
||||
kill_fn — опциональная функция завершения (например ``docker kill``) для очистки.
|
||||
"""
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
env=env,
|
||||
cwd=cwd,
|
||||
start_new_session=new_session,
|
||||
shell=shell,
|
||||
)
|
||||
proc._cobot_new_session = new_session # type: ignore[attr-defined]
|
||||
proc._cobot_kill_fn = kill_fn # type: ignore[attr-defined]
|
||||
_register(proc)
|
||||
return proc
|
||||
|
||||
|
||||
def stream(
|
||||
cmd: Sequence[str],
|
||||
*,
|
||||
env: Optional[dict] = None,
|
||||
cwd: Optional[str] = None,
|
||||
on_line: Optional[LineHook] = None,
|
||||
new_session: bool = False,
|
||||
shell: bool = False,
|
||||
echo: bool = True,
|
||||
kill_fn: Optional[Callable] = None,
|
||||
) -> int:
|
||||
"""Run a command and stream every output line to the console (and on_line hook).
|
||||
|
||||
Returns the process exit code. SIGKILL (-9) / SIGTERM (-15) are returned as-is so
|
||||
callers can treat user cancellation differently from real failures.
|
||||
|
||||
Запускает команду и транслирует каждую строку вывода в консоль (и в колбэк on_line).
|
||||
Возвращает код возврата процесса. SIGKILL (-9) / SIGTERM (-15) возвращаются как есть,
|
||||
чтобы вызывающий код мог отличать отмену пользователем от реальных ошибок.
|
||||
"""
|
||||
proc = spawn(cmd, env=env, cwd=cwd, new_session=new_session, shell=shell, kill_fn=kill_fn)
|
||||
try:
|
||||
for line in proc.stdout:
|
||||
s = line.rstrip()
|
||||
if on_line is not None:
|
||||
on_line(s)
|
||||
elif echo and s:
|
||||
console.print(f" [dim]{_escape(s)}[/dim]")
|
||||
proc.wait()
|
||||
finally:
|
||||
_unregister(proc)
|
||||
return proc.returncode
|
||||
|
||||
|
||||
def _escape(s: str) -> str:
|
||||
"""Escape Rich markup so raw command output is never interpreted as markup.
|
||||
Экранирует разметку Rich, чтобы сырой вывод команды не интерпретировался как разметка.
|
||||
"""
|
||||
return s.replace("[", "\\[")
|
||||
|
||||
|
||||
# A progress bar that sticks to the bottom while log lines scroll above it.
|
||||
# Прогресс-бар, "прилипающий" к низу, пока строки лога прокручиваются над ним.
|
||||
def make_progress() -> Progress:
|
||||
"""Create a Progress with a spinner, bar, percentage, and description column.
|
||||
Создаёт Progress со спиннером, баром, процентами и колонкой описания.
|
||||
"""
|
||||
return Progress(
|
||||
SpinnerColumn(),
|
||||
BarColumn(bar_width=30),
|
||||
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
||||
TextColumn("[dim]{task.description}[/dim]"),
|
||||
console=console,
|
||||
transient=True,
|
||||
)
|
||||
|
||||
|
||||
def run_step(
|
||||
title: str,
|
||||
cmd: Sequence[str],
|
||||
*,
|
||||
env: Optional[dict] = None,
|
||||
cwd: Optional[str] = None,
|
||||
new_session: bool = False,
|
||||
shell: bool = False,
|
||||
show_progress: bool = True,
|
||||
total: float = 100.0,
|
||||
parse_progress: Optional[Callable[[str], Optional[tuple]]] = None,
|
||||
on_line: Optional[LineHook] = None,
|
||||
success_msg: str = "",
|
||||
fail_msg: str = "",
|
||||
finish: bool = True,
|
||||
) -> int:
|
||||
"""Run one command as a self-contained "block": header, live log + progress, status.
|
||||
|
||||
parse_progress(line) may return (pct, label) to advance the bar, or None to ignore.
|
||||
Returns the exit code. Prints a ✓/✗ line unless finish=False (used when chaining
|
||||
several commands under one header).
|
||||
|
||||
Запускает одну команду как самодостаточный "блок": заголовок, живой лог + прогресс,
|
||||
статус. parse_progress(line) может вернуть (pct, label) для продвижения бара или None.
|
||||
Возвращает код возврата. Печатает строку ✓/✗, если finish=True (иначе — при цепочке
|
||||
нескольких команд под одним заголовком).
|
||||
"""
|
||||
if title:
|
||||
header(title)
|
||||
|
||||
if not show_progress:
|
||||
rc = stream(cmd, env=env, cwd=cwd, on_line=on_line,
|
||||
new_session=new_session, shell=shell)
|
||||
else:
|
||||
progress = make_progress()
|
||||
with progress:
|
||||
task = progress.add_task("", total=total)
|
||||
|
||||
def _line(s: str) -> None:
|
||||
if parse_progress is not None:
|
||||
parsed = parse_progress(s)
|
||||
if parsed is not None:
|
||||
pct, label = parsed
|
||||
progress.update(task, completed=pct,
|
||||
description=label or "")
|
||||
if on_line is not None:
|
||||
on_line(s)
|
||||
elif s:
|
||||
progress.console.print(f" [dim]{_escape(s)}[/dim]")
|
||||
|
||||
rc = stream(cmd, env=env, cwd=cwd, on_line=_line,
|
||||
new_session=new_session, shell=shell)
|
||||
progress.update(task, completed=total)
|
||||
|
||||
ok = rc in (0, -9, -15)
|
||||
if finish:
|
||||
if ok:
|
||||
done(True, success_msg or "Готово")
|
||||
else:
|
||||
done(False, fail_msg or f"Команда завершилась с кодом {rc}")
|
||||
return rc
|
||||
|
||||
|
||||
# A live progress context for tasks that run several commands or Python work and
|
||||
# need to drive the bar manually. Yields a small controller with .log()/.set().
|
||||
# Живой контекст прогресса для задач, выполняющих несколько команд или Python-работу
|
||||
# и управляющих баром вручную. Отдаёт небольшой контроллер с .log()/.set().
|
||||
class StepProgress:
|
||||
"""Manual progress controller used as a context manager.
|
||||
|
||||
Usage:
|
||||
with StepProgress("Building") as p:
|
||||
p.set(10, "step one")
|
||||
p.log("some output")
|
||||
|
||||
Ручной контроллер прогресса, используемый как менеджер контекста.
|
||||
"""
|
||||
|
||||
def __init__(self, title: str, total: float = 100.0, show: bool = True):
|
||||
if title:
|
||||
header(title)
|
||||
self._total = total
|
||||
self._show = show
|
||||
self._progress: Optional[Progress] = None
|
||||
self._task = None
|
||||
|
||||
def __enter__(self) -> "StepProgress":
|
||||
if self._show:
|
||||
self._progress = make_progress()
|
||||
self._progress.__enter__()
|
||||
self._task = self._progress.add_task("", total=self._total)
|
||||
return self
|
||||
|
||||
def set(self, pct: float, label: str = "") -> None:
|
||||
if self._progress is not None:
|
||||
self._progress.update(self._task, completed=pct, description=label or "")
|
||||
|
||||
def log(self, line: str, style: str = "dim") -> None:
|
||||
out = self._progress.console if self._progress is not None else console
|
||||
if line == "":
|
||||
out.print()
|
||||
else:
|
||||
out.print(f" [{style}]{_escape(line)}[/{style}]" if style else f" {line}")
|
||||
|
||||
def raw(self, renderable) -> None:
|
||||
"""Print a pre-built Rich renderable/markup string without escaping.
|
||||
Печатает готовый Rich-объект/строку с разметкой без экранирования.
|
||||
"""
|
||||
out = self._progress.console if self._progress is not None else console
|
||||
out.print(renderable)
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> None:
|
||||
if self._progress is not None:
|
||||
self._progress.__exit__(exc_type, exc, tb)
|
||||
self._progress = None
|
||||
-463
@@ -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
@@ -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,12 +2,12 @@ from setuptools import setup, find_packages
|
||||
|
||||
setup(
|
||||
name="lightweight-cobot",
|
||||
version="2026.05.31",
|
||||
version="2026.06.11",
|
||||
description="CLI tool for installing, configuring and managing the ROS 2 cobot workspace",
|
||||
packages=find_packages(),
|
||||
python_requires=">=3.11",
|
||||
install_requires=[
|
||||
"textual",
|
||||
"rich",
|
||||
"ruamel.yaml",
|
||||
],
|
||||
entry_points={
|
||||
|
||||
Reference in New Issue
Block a user