from __future__ import annotations import argparse 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.commands.docker_setup import run as _docker_setup # Root directory of the project, used as the working directory for colcon builds. # Корневая директория проекта, используется как рабочая директория для сборки colcon. _PROJECT_DIR = Path(__file__).parent.parent.parent # ROS2 distribution name targeted by this installer. # Название дистрибутива ROS2, который устанавливает этот скрипт. _DISTRO = "jazzy" # Webots simulator version targeted by this installer. # Версия симулятора Webots, устанавливаемая этим скриптом. _WEBOTS_VERSION = "2025a" # Directory that contains the shell scripts used by this command. # Директория с 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] # OS and tool detection helpers # Вспомогательные функции для определения ОС и наличия инструментов 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(): return False info: dict[str, str] = {} for line in path.read_text().splitlines(): if "=" in line: k, _, v = line.partition("=") info[k.strip()] = v.strip().strip('"') return info.get("ID") == "ubuntu" and info.get("VERSION_ID") == "24.04" 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() def webots_installed() -> bool: """Return True if the Webots binary is available on PATH. Возвращает True, если бинарный файл Webots доступен в PATH. """ return shutil.which("webots") is not None def _ros2_env() -> dict: """Build an environment dict with ROS2 variables sourced from setup.bash. Sources /opt/ros/jazzy/setup.bash in a subprocess, captures all exported variables and merges them into a copy of os.environ. Falls back to plain 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") if not setup.exists(): return os.environ.copy() result = subprocess.run( ["bash", "-c", f"source {setup} && env"], capture_output=True, text=True, ) env = os.environ.copy() for line in result.stdout.splitlines(): if "=" in line: k, _, v = line.partition("=") env[k] = v # Put system dirs first so ament_cmake picks up system Python (where catkin_pkg # lives) instead of a user-local Python installed by uv or pyenv. # Ставим системные пути первыми, чтобы ament_cmake использовал системный Python # (где установлен catkin_pkg), а не пользовательский Python от uv или pyenv. _SYSTEM_PATHS = ["/usr/bin", "/usr/local/bin"] existing = env.get("PATH", "").split(":") env["PATH"] = ":".join( _SYSTEM_PATHS + [p for p in existing if p not in _SYSTEM_PATHS] ) 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 считается нормальной отменой и не вызывает исключение). """ 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]}") # 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::