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:
+240
-389
@@ -5,11 +5,10 @@ import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Callable, List, Optional
|
||||
|
||||
from textual.app import App
|
||||
|
||||
from cobot.tui import SCREEN_CSS, LogScreen, PickScreen
|
||||
from cobot import process, ui
|
||||
from cobot import privilege
|
||||
from cobot.ui import done, header
|
||||
from cobot.commands.docker_setup import run as _docker_setup
|
||||
|
||||
# Root directory of the project, used as the working directory for colcon builds.
|
||||
@@ -28,9 +27,12 @@ _WEBOTS_VERSION = "2025a"
|
||||
# Директория с shell-скриптами, используемыми этой командой.
|
||||
_SCRIPTS_DIR = _PROJECT_DIR / "scripts"
|
||||
|
||||
# Type alias for the callable used to write a line to the TUI log screen.
|
||||
# Псевдоним типа для функции записи строки в лог TUI.
|
||||
Write = Callable[[str], None]
|
||||
# apt packages that must exist before rosdep can install the pip-based keys
|
||||
# (python3-pip / dev / venv). Their absence is what produced the "pip is not
|
||||
# installed" failure in the screenshots.
|
||||
# apt-пакеты, необходимые до того как rosdep сможет установить pip-зависимости.
|
||||
# Именно их отсутствие давало ошибку "pip is not installed" на скриншотах.
|
||||
_APT_PREREQS = ["python3-pip", "python3-dev", "python3-venv"]
|
||||
|
||||
|
||||
# OS and tool detection helpers
|
||||
@@ -38,9 +40,7 @@ Write = Callable[[str], None]
|
||||
def _detect_ubuntu_2404() -> bool:
|
||||
"""Return True if the current OS is Ubuntu 24.04 (Noble).
|
||||
|
||||
Reads /etc/os-release and checks the ID and VERSION_ID fields.
|
||||
Возвращает True, если текущая ОС - Ubuntu 24.04 (Noble).
|
||||
Читает /etc/os-release и проверяет поля ID и VERSION_ID.
|
||||
"""
|
||||
path = Path("/etc/os-release")
|
||||
if not path.exists():
|
||||
@@ -55,7 +55,6 @@ def _detect_ubuntu_2404() -> bool:
|
||||
|
||||
def _detect_ros2() -> bool:
|
||||
"""Return True if ROS2 Jazzy is already installed under /opt/ros/jazzy.
|
||||
|
||||
Возвращает True, если ROS2 Jazzy уже установлен в /opt/ros/jazzy.
|
||||
"""
|
||||
return Path(f"/opt/ros/{_DISTRO}").is_dir()
|
||||
@@ -63,7 +62,6 @@ def _detect_ros2() -> bool:
|
||||
|
||||
def webots_installed() -> bool:
|
||||
"""Return True if the Webots binary is available on PATH.
|
||||
|
||||
Возвращает True, если бинарный файл Webots доступен в PATH.
|
||||
"""
|
||||
return shutil.which("webots") is not None
|
||||
@@ -77,8 +75,6 @@ def _ros2_env() -> dict:
|
||||
os.environ if the setup file does not exist yet.
|
||||
|
||||
Формирует словарь окружения с переменными ROS2, полученными из setup.bash.
|
||||
Запускает /opt/ros/jazzy/setup.bash в подпроцессе, перехватывает все
|
||||
экспортированные переменные и объединяет их с копией os.environ.
|
||||
Возвращает чистый os.environ если файл setup.bash ещё не существует.
|
||||
"""
|
||||
setup = Path(f"/opt/ros/{_DISTRO}/setup.bash")
|
||||
@@ -96,15 +92,13 @@ def _ros2_env() -> dict:
|
||||
|
||||
# CMake's find_package(Python3) ignores PATH and uses its own search logic,
|
||||
# so we must pin it explicitly to the system Python where catkin_pkg is installed.
|
||||
# PATH reordering alone is not enough.
|
||||
# CMake игнорирует PATH при поиске Python через find_package(Python3),
|
||||
# поэтому явно указываем системный Python, где установлен catkin_pkg.
|
||||
# Одного изменения PATH недостаточно.
|
||||
env["Python3_EXECUTABLE"] = "/usr/bin/python3"
|
||||
env["PYTHON_EXECUTABLE"] = "/usr/bin/python3"
|
||||
|
||||
# Also keep PATH clean so other tools (rosdep, colcon itself) use system Python.
|
||||
# Заодно чистим PATH чтобы другие инструменты тоже использовали системный Python.
|
||||
# Keep PATH clean so other tools (rosdep, colcon itself) use system Python.
|
||||
# Чистим PATH чтобы другие инструменты тоже использовали системный Python.
|
||||
_SYSTEM_PATHS = ["/usr/bin", "/usr/local/bin"]
|
||||
existing = env.get("PATH", "").split(":")
|
||||
env["PATH"] = ":".join(
|
||||
@@ -113,393 +107,268 @@ def _ros2_env() -> dict:
|
||||
return env
|
||||
|
||||
|
||||
# Subprocess runner helpers
|
||||
# Вспомогательные функции для запуска подпроцессов
|
||||
def _run_logged(
|
||||
cmd: List[str],
|
||||
write: Write,
|
||||
env: dict | None = None,
|
||||
cwd=None,
|
||||
register_proc: Callable | None = None,
|
||||
) -> None:
|
||||
"""Run a command and stream every non-empty output line to the TUI log.
|
||||
|
||||
Raises RuntimeError if the process exits with a non-zero code (SIGKILL is
|
||||
treated as a normal cancellation and does not raise).
|
||||
|
||||
Запускает команду и передаёт каждую непустую строку вывода в лог TUI.
|
||||
Выбрасывает RuntimeError если процесс завершился с ненулевым кодом
|
||||
(SIGKILL считается нормальной отменой и не вызывает исключение).
|
||||
# Bash-script runner that understands PROGRESS:<pct>:<label> markers
|
||||
# Запуск bash-скриптов с поддержкой маркеров PROGRESS:<pct>:<метка>
|
||||
def _run_script(script: Path, title: str) -> int:
|
||||
"""Run a shell script, streaming its output to a live log and advancing the
|
||||
progress bar from PROGRESS:<pct>:<label> markers (which are not echoed raw).
|
||||
Returns the script exit code.
|
||||
Запускает shell-скрипт, транслируя вывод в живой лог и продвигая прогресс-бар по
|
||||
маркерам PROGRESS:<pct>:<метка> (сами маркеры не печатаются). Возвращает код возврата.
|
||||
"""
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
env=env or os.environ,
|
||||
cwd=cwd,
|
||||
)
|
||||
if register_proc:
|
||||
register_proc(proc)
|
||||
for line in proc.stdout:
|
||||
s = line.rstrip()
|
||||
if s:
|
||||
write(s)
|
||||
proc.wait()
|
||||
if proc.returncode not in (0, -9):
|
||||
raise RuntimeError(f"Command failed: {cmd[0]}")
|
||||
if not script.exists():
|
||||
header(title)
|
||||
ui.error(f"Скрипт не найден: {script}")
|
||||
done(False, "Скрипт отсутствует")
|
||||
return 1
|
||||
|
||||
with process.StepProgress(title) as p:
|
||||
def on_line(s: str) -> None:
|
||||
if s.startswith("PROGRESS:"):
|
||||
parts = s.split(":", 2)
|
||||
try:
|
||||
p.set(float(parts[1]), parts[2] if len(parts) > 2 else "")
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
return
|
||||
if s:
|
||||
p.log(s)
|
||||
|
||||
rc = process.stream(["bash", str(script)], cwd=str(_PROJECT_DIR), on_line=on_line)
|
||||
|
||||
ok = rc in (0, -9, -15)
|
||||
done(ok, "Готово" if ok else f"Скрипт завершился с кодом {rc}")
|
||||
return rc
|
||||
|
||||
|
||||
# Tasks - long-running functions executed inside a LogScreen background thread
|
||||
# Задачи - долгие функции, выполняемые в фоновом потоке внутри LogScreen
|
||||
def _run_script(script: Path, screen: LogScreen) -> None:
|
||||
"""Run a shell script, stream its output to the TUI log, and parse
|
||||
PROGRESS:<pct>:<label> markers to update the progress bar.
|
||||
Raises RuntimeError if the script exits with a non-zero code.
|
||||
Запускает shell-скрипт, транслирует вывод в лог TUI и разбирает маркеры
|
||||
PROGRESS:<pct>:<метка> для обновления прогресс-бара.
|
||||
Выбрасывает RuntimeError если скрипт завершился с ненулевым кодом.
|
||||
"""
|
||||
proc = subprocess.Popen(
|
||||
["bash", str(script)],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
text=True, cwd=_PROJECT_DIR,
|
||||
)
|
||||
screen.set_proc(proc)
|
||||
for line in proc.stdout:
|
||||
s = line.rstrip()
|
||||
if s.startswith("PROGRESS:"):
|
||||
# Format emitted by scripts: PROGRESS:<pct>:<label>
|
||||
# Формат, выводимый скриптами: PROGRESS:<pct>:<метка>
|
||||
parts = s.split(":", 2)
|
||||
try:
|
||||
screen.set_progress(float(parts[1]), parts[2] if len(parts) > 2 else "")
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
elif s:
|
||||
screen.write(s)
|
||||
proc.wait()
|
||||
if proc.returncode not in (0, -9):
|
||||
raise RuntimeError(f"Script failed (exit {proc.returncode}): {script.name}")
|
||||
|
||||
|
||||
def _task_install(screen: LogScreen, pkg: str) -> None:
|
||||
"""Run the ROS2 Jazzy installation shell script for the chosen variant (desktop / ros-base).
|
||||
The script emits PROGRESS: markers so the bar advances during installation.
|
||||
def install_ros2(pkg: str) -> bool:
|
||||
"""Run the ROS2 Jazzy install shell script for the chosen variant (desktop / ros-base).
|
||||
Запускает shell-скрипт установки ROS2 Jazzy для выбранного варианта (desktop / ros-base).
|
||||
Скрипт выводит маркеры PROGRESS:, чтобы прогресс-бар обновлялся во время установки.
|
||||
"""
|
||||
script = _SCRIPTS_DIR / f"setup_ros2_{pkg.replace('-', '_')}.sh"
|
||||
rc = _run_script(script, f"Установка ROS2 {_DISTRO} ({pkg})")
|
||||
return rc in (0, -9, -15)
|
||||
|
||||
|
||||
def install_webots() -> bool:
|
||||
"""Run the Webots installation shell script.
|
||||
Запускает shell-скрипт установки Webots.
|
||||
"""
|
||||
script = _SCRIPTS_DIR / "install_webots.sh"
|
||||
rc = _run_script(script, f"Установка Webots {_WEBOTS_VERSION}")
|
||||
return rc in (0, -9, -15)
|
||||
|
||||
|
||||
# Build prerequisites
|
||||
# Предусловия сборки
|
||||
def _missing_apt_prereqs() -> list[str]:
|
||||
"""Return the subset of _APT_PREREQS that is not currently installed via dpkg.
|
||||
Возвращает подмножество _APT_PREREQS, которое сейчас не установлено через dpkg.
|
||||
"""
|
||||
missing = []
|
||||
for pkg in _APT_PREREQS:
|
||||
r = subprocess.run(
|
||||
["dpkg-query", "-W", "-f=${Status}", pkg],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if "install ok installed" not in r.stdout:
|
||||
missing.append(pkg)
|
||||
return missing
|
||||
|
||||
|
||||
def _ensure_root_pip_break(p: process.StepProgress) -> None:
|
||||
"""Let root's pip override PEP 668, scoped to /root/.config/pip/pip.conf.
|
||||
|
||||
rosdep installs the pip-based rosdep keys (fastapi, uvicorn, multipart, fastmcp)
|
||||
as root via sudo; on Ubuntu 24.04 that is blocked by PEP 668 unless break-system-
|
||||
packages is allowed. Writing root's pip config is idempotent, reversible (just
|
||||
delete the file), and does not touch the user's own pip configuration.
|
||||
|
||||
Разрешает pip от root обходить PEP 668, ограничиваясь /root/.config/pip/pip.conf.
|
||||
rosdep ставит pip-зависимости от root через sudo; на Ubuntu 24.04 это блокируется
|
||||
PEP 668, пока не разрешён break-system-packages. Запись конфига pip от root
|
||||
идемпотентна, обратима (удалить файл) и не трогает пользовательский pip.
|
||||
"""
|
||||
snippet = (
|
||||
"mkdir -p /root/.config/pip && "
|
||||
"( grep -qs 'break-system-packages' /root/.config/pip/pip.conf || "
|
||||
"printf '[global]\\nbreak-system-packages = true\\n' "
|
||||
">> /root/.config/pip/pip.conf )"
|
||||
)
|
||||
process.stream(privilege.sudo(["bash", "-c", snippet]), on_line=p.log)
|
||||
|
||||
|
||||
def _register_rosdep_source(p: process.StepProgress, env: dict) -> None:
|
||||
"""Register the project's local rosdep.yaml as a rosdep source and run rosdep update.
|
||||
Only re-writes / updates when the source file is missing or out of date.
|
||||
Регистрирует локальный rosdep.yaml проекта как источник rosdep и запускает rosdep update.
|
||||
Перезаписывает/обновляет только если файл-источник отсутствует или устарел.
|
||||
"""
|
||||
rosdep_yaml = _PROJECT_DIR / "rosdep.yaml"
|
||||
if not rosdep_yaml.exists():
|
||||
return
|
||||
sources_list = Path("/etc/ros/rosdep/sources.list.d/50-kuka-local.list")
|
||||
entry = f"yaml file://{rosdep_yaml}\n"
|
||||
try:
|
||||
# "desktop" -> setup_ros2_desktop.sh, "ros-base" -> setup_ros2_ros_base.sh
|
||||
script = _SCRIPTS_DIR / f"setup_ros2_{pkg.replace('-', '_')}.sh"
|
||||
if not script.exists():
|
||||
screen.write(f"[red]Script not found:[/red] {script}")
|
||||
screen.finish(False)
|
||||
return
|
||||
screen.set_progress(0, "Starting installation...")
|
||||
_run_script(script, screen)
|
||||
if not screen.is_stopped():
|
||||
screen.set_progress(100, "Done")
|
||||
screen.write(f"\n[green]ROS2 {_DISTRO} ({pkg}) installed successfully.[/green]")
|
||||
screen.finish(True)
|
||||
except Exception as exc:
|
||||
if not screen.is_stopped():
|
||||
screen.write(f"\n[red]Error:[/red] {exc}")
|
||||
screen.finish(False)
|
||||
current = sources_list.read_text() if sources_list.exists() else ""
|
||||
except Exception:
|
||||
current = ""
|
||||
if current == entry:
|
||||
return
|
||||
snippet = (
|
||||
"mkdir -p /etc/ros/rosdep/sources.list.d && "
|
||||
f"printf '%s\\n' 'yaml file://{rosdep_yaml}' > {sources_list}"
|
||||
)
|
||||
process.stream(privilege.sudo(["bash", "-c", snippet]), on_line=p.log)
|
||||
p.log(f"Зарегистрирован локальный источник rosdep: {rosdep_yaml}")
|
||||
process.stream(["rosdep", "update"], env=env, cwd=str(_PROJECT_DIR), on_line=p.log)
|
||||
|
||||
|
||||
def _task_build(screen: LogScreen) -> None:
|
||||
"""Build the project workspace using rosdep and colcon.
|
||||
|
||||
Step 1 - runs "rosdep install --from-paths src" to pull in all package
|
||||
dependencies declared in the src/ directory.
|
||||
Step 2 - runs "colcon build --symlink-install" to compile every package.
|
||||
|
||||
Both commands receive a copy of os.environ extended with the sourced ROS2
|
||||
setup so that ament CMake macros and ROS2 packages are visible even if the
|
||||
user has not yet sourced setup.bash in this terminal session.
|
||||
|
||||
Собирает рабочее пространство проекта с помощью rosdep и colcon.
|
||||
Шаг 1 - запускает "rosdep install --from-paths src" для установки всех
|
||||
зависимостей пакетов, объявленных в директории src/.
|
||||
Шаг 2 - запускает "colcon build --symlink-install" для компиляции каждого пакета.
|
||||
|
||||
Обе команды получают копию os.environ с подключённым окружением ROS2, так что
|
||||
макросы ament CMake и пакеты ROS2 видны даже если пользователь ещё не выполнил
|
||||
source setup.bash в этой сессии терминала.
|
||||
def _count_colcon_packages(env: dict) -> int:
|
||||
"""Count colcon packages under src/ so the build bar can show X / total.
|
||||
Считает пакеты colcon в src/, чтобы бар сборки показывал X / всего.
|
||||
"""
|
||||
try:
|
||||
env = _ros2_env()
|
||||
|
||||
if not shutil.which("colcon") and not Path(f"/opt/ros/{_DISTRO}/bin/colcon").exists():
|
||||
screen.write("[red]colcon not found.[/red]")
|
||||
screen.write(f"Source ROS2 first: [bold]source /opt/ros/{_DISTRO}/setup.bash[/bold]")
|
||||
screen.finish(False)
|
||||
return
|
||||
|
||||
screen.set_progress(0, "Installing dependencies...")
|
||||
screen.write("[bold]Step 1 / 2 - rosdep install[/bold]\n")
|
||||
|
||||
rosdep_yaml = _PROJECT_DIR / "rosdep.yaml"
|
||||
sources_list = Path("/etc/ros/rosdep/sources.list.d/50-kuka-local.list")
|
||||
rosdep_entry = f"yaml file://{rosdep_yaml}\n"
|
||||
if rosdep_yaml.exists() and (
|
||||
not sources_list.exists() or sources_list.read_text() != rosdep_entry
|
||||
):
|
||||
try:
|
||||
sources_list.write_text(rosdep_entry)
|
||||
screen.write(f"Registered local rosdep source: {rosdep_yaml}")
|
||||
_run_logged(["rosdep", "update"], screen.write, env=env, cwd=_PROJECT_DIR)
|
||||
except PermissionError:
|
||||
_run_logged(
|
||||
["sudo", "bash", "-c",
|
||||
f"echo '{rosdep_entry.strip()}' > {sources_list}"],
|
||||
screen.write, env=env,
|
||||
)
|
||||
_run_logged(["rosdep", "update"], screen.write, env=env, cwd=_PROJECT_DIR)
|
||||
|
||||
_run_logged(
|
||||
["rosdep", "install", "--from-paths", "src", "-i", "-r", "-y"],
|
||||
screen.write,
|
||||
env=env,
|
||||
cwd=_PROJECT_DIR,
|
||||
register_proc=screen.set_proc,
|
||||
)
|
||||
if screen.is_stopped():
|
||||
return
|
||||
|
||||
screen.set_progress(30, "Building...")
|
||||
list_result = subprocess.run(
|
||||
["colcon", "list", "--base-paths", "src"], capture_output=True, text=True,
|
||||
cwd=_PROJECT_DIR, env=env,
|
||||
)
|
||||
total = max(len([l for l in list_result.stdout.splitlines() if l.strip()]), 1)
|
||||
screen.write(f"\n[bold]Step 2 / 2 - colcon build ({total} packages)[/bold]\n")
|
||||
built = 0
|
||||
|
||||
def _track(line: str) -> None:
|
||||
"""Update the progress bar each time colcon finishes a package.
|
||||
Обновляет прогресс-бар каждый раз, когда colcon завершает пакет.
|
||||
"""
|
||||
nonlocal built
|
||||
screen.write(line)
|
||||
if "Finished <<<" in line or "Failed <<<" in line:
|
||||
built += 1
|
||||
screen.set_progress(
|
||||
30 + built / total * 70,
|
||||
f"{built} / {total} packages done",
|
||||
)
|
||||
|
||||
_run_logged(
|
||||
["colcon", "build", "--base-paths", "src"],
|
||||
_track,
|
||||
env=env,
|
||||
cwd=_PROJECT_DIR,
|
||||
register_proc=screen.set_proc,
|
||||
)
|
||||
|
||||
if not screen.is_stopped():
|
||||
screen.set_progress(100, "Build complete")
|
||||
screen.write("\nActivate workspace: [bold]source install/setup.bash[/bold]")
|
||||
screen.finish(True)
|
||||
except Exception as exc:
|
||||
if not screen.is_stopped():
|
||||
screen.write(f"\n[red]Error:[/red] {exc}")
|
||||
screen.finish(False)
|
||||
r = subprocess.run(
|
||||
["colcon", "list", "--base-paths", "src"],
|
||||
capture_output=True, text=True, cwd=str(_PROJECT_DIR), env=env,
|
||||
)
|
||||
return max(len([l for l in r.stdout.splitlines() if l.strip()]), 1)
|
||||
|
||||
|
||||
def _task_install_webots(screen: LogScreen) -> None:
|
||||
"""Run the Webots installation shell script, streaming output and progress to the TUI.
|
||||
def build_workspace() -> bool:
|
||||
"""Build the workspace: apt prerequisites -> rosdep install -> colcon build.
|
||||
|
||||
Запускает shell-скрипт установки Webots, транслируя вывод и прогресс в TUI.
|
||||
Step 1 guarantees python3-pip/dev/venv and allows root pip under PEP 668 so the
|
||||
pip-based rosdep keys install cleanly. Step 2 runs rosdep install with
|
||||
PIP_BREAK_SYSTEM_PACKAGES=1. Step 3 compiles every package with live progress.
|
||||
|
||||
Собирает workspace: apt-предусловия -> rosdep install -> colcon build.
|
||||
Шаг 1 гарантирует python3-pip/dev/venv и разрешает pip от root под PEP 668. Шаг 2
|
||||
запускает rosdep install с PIP_BREAK_SYSTEM_PACKAGES=1. Шаг 3 компилирует все пакеты.
|
||||
"""
|
||||
try:
|
||||
script = _SCRIPTS_DIR / "install_webots.sh"
|
||||
if not script.exists():
|
||||
screen.write(f"[red]Script not found:[/red] {script}")
|
||||
screen.finish(False)
|
||||
return
|
||||
screen.set_progress(0, "Starting Webots installation...")
|
||||
_run_script(script, screen)
|
||||
if not screen.is_stopped():
|
||||
screen.set_progress(100, "Done")
|
||||
screen.write(f"\n[green]Webots {_WEBOTS_VERSION} installed successfully.[/green]")
|
||||
screen.finish(True)
|
||||
except Exception as exc:
|
||||
if not screen.is_stopped():
|
||||
screen.write(f"\n[red]Error:[/red] {exc}")
|
||||
screen.finish(False)
|
||||
env = _ros2_env()
|
||||
env["PIP_BREAK_SYSTEM_PACKAGES"] = "1"
|
||||
|
||||
if not shutil.which("colcon") and not Path(f"/opt/ros/{_DISTRO}/bin/colcon").exists():
|
||||
header("Сборка проекта")
|
||||
ui.error("colcon не найден.")
|
||||
ui.note(f"Сначала выполните: source /opt/ros/{_DISTRO}/setup.bash")
|
||||
done(False, "colcon недоступен")
|
||||
return False
|
||||
|
||||
# TUI application - orchestrates screens and user choices
|
||||
# TUI приложение - управляет экранами и выборами пользователя
|
||||
class _LocalSetupApp(App[Optional[str]]):
|
||||
"""Main TUI application for the local-setup command.
|
||||
ok = True
|
||||
fail_msg = ""
|
||||
|
||||
Guides the user through: install ROS2 choice, OS check, version choice,
|
||||
installation log, build log, and optional Webots installation.
|
||||
Returns "docker" if the user opts for Docker setup, None otherwise.
|
||||
|
||||
Главное TUI приложение для команды local-setup.
|
||||
Проводит пользователя через: выбор установки ROS2, проверку ОС, выбор версии,
|
||||
лог установки, лог сборки и опциональную установку Webots.
|
||||
Возвращает "docker" если пользователь выбирает Docker, иначе None.
|
||||
"""
|
||||
|
||||
CSS = SCREEN_CSS
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.push_screen(
|
||||
PickScreen(
|
||||
"local-setup",
|
||||
"Install ROS2 Jazzy?",
|
||||
["Yes, install", "No, exit"],
|
||||
"Yes, install",
|
||||
),
|
||||
self._on_install_choice,
|
||||
)
|
||||
|
||||
def _on_install_choice(self, choice: Optional[str]) -> None:
|
||||
"""Handle the initial yes/no choice to install ROS2.
|
||||
Обрабатывает начальный выбор да/нет для установки ROS2.
|
||||
"""
|
||||
if not choice or choice.startswith("No"):
|
||||
self.exit(None)
|
||||
return
|
||||
if not _detect_ubuntu_2404():
|
||||
self.push_screen(
|
||||
PickScreen(
|
||||
"Unsupported OS",
|
||||
"Ubuntu 24.04 not detected. Set up the environment via Docker instead?",
|
||||
["Yes, run docker-setup", "No, exit"],
|
||||
"Yes, run docker-setup",
|
||||
),
|
||||
self._on_docker_choice,
|
||||
with process.StepProgress("Сборка проекта") as p:
|
||||
# --- Шаг 1/3: системные зависимости pip (apt) ---
|
||||
p.raw("[bold]Шаг 1/3 — системные зависимости pip (apt)[/bold]")
|
||||
p.set(0, "Проверка python3-pip / dev / venv...")
|
||||
missing = _missing_apt_prereqs()
|
||||
if missing:
|
||||
p.log(f"Установка: {', '.join(missing)}")
|
||||
process.stream(privilege.sudo(["apt-get", "update", "-q"]), env=env, on_line=p.log)
|
||||
rc = process.stream(
|
||||
privilege.sudo(["apt-get", "install", "-y", *missing]),
|
||||
env=env, on_line=p.log,
|
||||
)
|
||||
if rc not in (0, -9, -15):
|
||||
ok, fail_msg = False, "Не удалось установить apt-зависимости"
|
||||
else:
|
||||
self.push_screen(
|
||||
PickScreen(
|
||||
"ROS2 version",
|
||||
"Which ROS2 Jazzy variant do you want to install?",
|
||||
["Desktop (full install, includes GUI tools)", "Base (minimal, no GUI)"],
|
||||
"Desktop (full install, includes GUI tools)",
|
||||
),
|
||||
self._on_version_choice,
|
||||
p.log("python3-pip / dev / venv уже установлены")
|
||||
if ok:
|
||||
_ensure_root_pip_break(p)
|
||||
|
||||
# --- Шаг 2/3: rosdep install ---
|
||||
if ok:
|
||||
p.set(10, "rosdep install...")
|
||||
p.raw("\n[bold]Шаг 2/3 — rosdep install[/bold]")
|
||||
_register_rosdep_source(p, env)
|
||||
rc = process.stream(
|
||||
["rosdep", "install", "--from-paths", "src", "-i", "-r", "-y"],
|
||||
env=env, cwd=str(_PROJECT_DIR), on_line=p.log,
|
||||
)
|
||||
if rc not in (0, -9, -15):
|
||||
ok, fail_msg = False, "rosdep install завершился с ошибкой"
|
||||
|
||||
def _on_docker_choice(self, choice: Optional[str]) -> None:
|
||||
"""Exit the app signalling whether docker-setup should be launched.
|
||||
Завершает приложение, сигнализируя нужно ли запустить docker-setup.
|
||||
"""
|
||||
self.exit("docker" if choice and choice.startswith("Yes") else None)
|
||||
# --- Шаг 3/3: colcon build ---
|
||||
if ok:
|
||||
total = _count_colcon_packages(env)
|
||||
p.set(30, f"0 / {total} пакетов")
|
||||
p.raw(f"\n[bold]Шаг 3/3 — colcon build ({total} пакетов)[/bold]")
|
||||
built = 0
|
||||
|
||||
def _on_version_choice(self, choice: Optional[str]) -> None:
|
||||
"""Start the installation log screen for the chosen ROS2 variant.
|
||||
Запускает экран лога установки для выбранного варианта ROS2.
|
||||
"""
|
||||
if not choice:
|
||||
self.exit(None)
|
||||
return
|
||||
pkg = "desktop" if choice.startswith("Desktop") else "ros-base"
|
||||
self.push_screen(
|
||||
LogScreen(
|
||||
f"Installing ROS2 Jazzy ({pkg})",
|
||||
lambda s: _task_install(s, pkg),
|
||||
show_progress=True,
|
||||
),
|
||||
lambda _: self._after_install(),
|
||||
)
|
||||
def _on_build(s: str) -> None:
|
||||
nonlocal built
|
||||
if s:
|
||||
p.log(s)
|
||||
if "Finished <<<" in s or "Failed <<<" in s:
|
||||
built += 1
|
||||
p.set(30 + built / total * 70, f"{built} / {total} пакетов")
|
||||
|
||||
def _after_install(self) -> None:
|
||||
"""After installation, ask whether to build the project workspace now.
|
||||
После установки спрашивает, нужно ли собрать рабочее пространство прямо сейчас.
|
||||
"""
|
||||
self.push_screen(
|
||||
PickScreen(
|
||||
"Build",
|
||||
"Build the project workspace now?\n(runs rosdep install + colcon build)",
|
||||
["Yes, build now", "No, skip"],
|
||||
"Yes, build now",
|
||||
),
|
||||
self._on_build_choice,
|
||||
)
|
||||
|
||||
def _on_build_choice(self, choice: Optional[str]) -> None:
|
||||
"""Start the build log screen or skip directly to the Webots prompt.
|
||||
Запускает экран сборки или пропускает к вопросу про Webots.
|
||||
"""
|
||||
if not choice or choice.startswith("No"):
|
||||
self._after_build()
|
||||
return
|
||||
self.push_screen(
|
||||
LogScreen("Building project", _task_build, show_progress=True),
|
||||
lambda _: self._after_build(),
|
||||
)
|
||||
|
||||
def _after_build(self) -> None:
|
||||
"""After the build, offer to install Webots if it is not already present.
|
||||
После сборки предлагает установить Webots если он ещё не установлен.
|
||||
"""
|
||||
if webots_installed():
|
||||
self.exit(None)
|
||||
return
|
||||
self.push_screen(
|
||||
PickScreen(
|
||||
"Webots",
|
||||
f"Install Webots {_WEBOTS_VERSION} simulator?",
|
||||
[f"Yes, install Webots {_WEBOTS_VERSION}", "No, skip"],
|
||||
"No, skip",
|
||||
),
|
||||
self._on_webots_choice,
|
||||
)
|
||||
|
||||
def _on_webots_choice(self, choice: Optional[str]) -> None:
|
||||
"""Start the Webots installer or exit depending on the user choice.
|
||||
Запускает установщик Webots или завершает работу в зависимости от выбора.
|
||||
"""
|
||||
if choice and choice.startswith("Yes"):
|
||||
subprocess.run(["sudo", "-v"], check=False)
|
||||
self.push_screen(
|
||||
LogScreen(
|
||||
f"Installing Webots {_WEBOTS_VERSION}",
|
||||
_task_install_webots,
|
||||
show_progress=True,
|
||||
),
|
||||
lambda _: self.exit(None),
|
||||
rc = process.stream(
|
||||
["colcon", "build", "--base-paths", "src"],
|
||||
env=env, cwd=str(_PROJECT_DIR), on_line=_on_build,
|
||||
)
|
||||
else:
|
||||
self.exit(None)
|
||||
if rc not in (0, -9, -15):
|
||||
ok, fail_msg = False, "colcon build завершился с ошибкой"
|
||||
else:
|
||||
p.set(100, "Готово")
|
||||
|
||||
done(ok, "Сборка завершена" if ok else fail_msg)
|
||||
if ok:
|
||||
ui.note("Активируйте окружение: source install/setup.bash")
|
||||
return ok
|
||||
|
||||
|
||||
class WebotsInstallApp(App[bool]):
|
||||
"""Standalone TUI app for installing Webots, used by the run command.
|
||||
|
||||
Launched by run.py when the user starts a local simulation but Webots
|
||||
is not installed yet.
|
||||
|
||||
Отдельное TUI приложение для установки Webots, используемое командой run.
|
||||
Запускается из run.py когда пользователь запускает локальную симуляцию,
|
||||
но Webots ещё не установлен.
|
||||
# Interactive flow
|
||||
# Интерактивный сценарий
|
||||
def run(args: argparse.Namespace) -> None:
|
||||
"""Guide the user through installing ROS2 Jazzy and building the workspace.
|
||||
Проводит пользователя через установку ROS2 Jazzy и сборку workspace.
|
||||
"""
|
||||
header("Локальная установка", "ROS2 Jazzy + сборка проекта")
|
||||
|
||||
CSS = SCREEN_CSS
|
||||
choice = ui.select("Установить ROS2 Jazzy?", ["Да, установить", "Нет, выход"],
|
||||
"Да, установить")
|
||||
if not choice or choice.startswith("Нет"):
|
||||
return
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.push_screen(
|
||||
LogScreen(
|
||||
f"Installing Webots {_WEBOTS_VERSION}",
|
||||
_task_install_webots,
|
||||
show_progress=True,
|
||||
),
|
||||
self.exit,
|
||||
# Acquire sudo once, up front, with the masked prompt + keep-alive thread.
|
||||
# Получаем sudo один раз, заранее, с маскированным вводом + keep-alive потоком.
|
||||
if not privilege.ensure_sudo():
|
||||
return
|
||||
|
||||
if not _detect_ubuntu_2404():
|
||||
v = ui.select(
|
||||
"Ubuntu 24.04 не обнаружена. Настроить окружение через Docker?",
|
||||
["Да, запустить docker-setup", "Нет, выход"],
|
||||
"Да, запустить docker-setup",
|
||||
)
|
||||
if v and v.startswith("Да"):
|
||||
_docker_setup(args)
|
||||
return
|
||||
|
||||
variant = ui.select(
|
||||
"Какой вариант ROS2 Jazzy установить?",
|
||||
["Desktop (полный, с GUI-инструментами)", "Base (минимальный, без GUI)"],
|
||||
"Desktop (полный, с GUI-инструментами)",
|
||||
)
|
||||
if not variant:
|
||||
return
|
||||
pkg = "desktop" if variant.startswith("Desktop") else "ros-base"
|
||||
if not install_ros2(pkg):
|
||||
return
|
||||
|
||||
if ui.confirm("Собрать workspace сейчас? (rosdep install + colcon build)", default=True):
|
||||
build_workspace()
|
||||
|
||||
if not webots_installed():
|
||||
if ui.confirm(f"Установить симулятор Webots {_WEBOTS_VERSION}?", default=False):
|
||||
install_webots()
|
||||
|
||||
|
||||
# Entry point - registered as the "local-setup" subcommand
|
||||
# Точка входа - зарегистрирована как подкоманда "local-setup"
|
||||
def register(subparsers: argparse._SubParsersAction) -> None:
|
||||
"""Register the local-setup subcommand with the CLI argument parser.
|
||||
|
||||
Регистрирует подкоманду local-setup в парсере аргументов командной строки.
|
||||
"""
|
||||
p = subparsers.add_parser(
|
||||
@@ -507,21 +376,3 @@ def register(subparsers: argparse._SubParsersAction) -> None:
|
||||
help="Install ROS2 Jazzy natively and build the project with colcon",
|
||||
)
|
||||
p.set_defaults(func=run)
|
||||
|
||||
|
||||
def run(args: argparse.Namespace) -> None:
|
||||
"""Entry point for the local-setup command.
|
||||
|
||||
Pre-caches the sudo token while the terminal is in normal mode so that
|
||||
subsequent sudo calls inside the Textual TUI do not hang waiting for
|
||||
a password prompt that the user cannot see.
|
||||
|
||||
Точка входа для команды local-setup.
|
||||
Предварительно кеширует sudo-токен пока терминал в обычном режиме, чтобы
|
||||
последующие вызовы sudo внутри Textual TUI не зависали ожидая запрос пароля,
|
||||
который пользователь не может увидеть.
|
||||
"""
|
||||
subprocess.run(["sudo", "-v"], check=False)
|
||||
result = _LocalSetupApp().run()
|
||||
if result == "docker":
|
||||
_docker_setup(args)
|
||||
|
||||
Reference in New Issue
Block a user