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

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