Add local setup command and enhance docker setup wizard

This commit is contained in:
Даниил Грабарь
2026-05-20 17:05:01 +03:00
parent 86e6e801e9
commit bbfec17876
6 changed files with 311 additions and 6 deletions
View File
+4 -1
View File
@@ -3,6 +3,7 @@ import sys
from cobot.commands import docker_setup as cmd_docker_setup
from cobot.commands import doc_setup as cmd_doc_setup
from cobot.commands import local_setup as cmd_local_setup
from cobot.commands import robot_setup as cmd_robot_setup
from cobot.commands import setup as cmd_setup
@@ -10,7 +11,8 @@ from cobot.commands import setup as cmd_setup
# Add new commands here when introducing other categories.
_GROUPS = [
("Setup", [
("setup", "run doc-setup + docker-setup + robot-setup in one go"),
("setup", "first-time setup: docs, build environment, robot config"),
("local-setup", "install ROS2 Jazzy natively and build the project with colcon"),
("docker-setup", "build or pull Docker images for KUKA iiwa7"),
("doc-setup", "deploy or stop the MkDocs documentation server"),
("robot-setup", "configure cobot-setting.yaml interactively"),
@@ -68,6 +70,7 @@ def main():
def _register_commands(subparsers):
cmd_setup.register(subparsers)
cmd_local_setup.register(subparsers)
cmd_docker_setup.register(subparsers)
cmd_doc_setup.register(subparsers)
cmd_robot_setup.register(subparsers)
+10 -2
View File
@@ -84,7 +84,15 @@ class _Wizard(App[Optional[_Config]]):
return
self._state["source"] = "build" if v == "Build locally" else "pull"
self.push_screen(
PickScreen("Step 3 of 5", "What to install:", ["Controller only", "Controller and Webots"], "Controller only"),
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,
)
@@ -92,7 +100,7 @@ class _Wizard(App[Optional[_Config]]):
if v is None:
self.exit(None)
return
self._state["variant"] = "webots" if v == "Controller and Webots" else "controller"
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,
+255
View File
@@ -0,0 +1,255 @@
from __future__ import annotations
import argparse
import os
import shutil
import subprocess
import sys
import tempfile
import urllib.request
from pathlib import Path
from typing import List, Optional
from rich.console import Console
from textual.app import App
from cobot.tui import SCREEN_CSS, PickScreen
from cobot.commands.docker_setup import run as _docker_setup
_console = Console()
_PROJECT_DIR = Path(__file__).parent.parent.parent
_ROS_KEYRING = Path("/usr/share/keyrings/ros-archive-keyring.gpg")
_ROS_SOURCES = Path("/etc/apt/sources.list.d/ros2.list")
_ROS_KEY_URL = "https://raw.githubusercontent.com/ros/rosdistro/master/ros.key"
def _detect_ubuntu_2404() -> bool:
os_release = Path("/etc/os-release")
if not os_release.exists():
return False
info: dict[str, str] = {}
for line in os_release.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_jazzy() -> bool:
return Path("/opt/ros/jazzy").is_dir()
def _run(cmd: List[str]) -> None:
result = subprocess.run(cmd)
if result.returncode != 0:
raise RuntimeError(f"Command failed: {' '.join(cmd)}")
def _check_output(cmd: List[str]) -> str:
return subprocess.check_output(cmd, text=True).strip()
def _step(msg: str) -> None:
_console.print(f"[cyan][*][/cyan] {msg}")
def _ok(msg: str) -> None:
_console.print(f"[green][ok][/green] {msg}")
def _apt_install(*packages: str) -> None:
_run(["sudo", "apt-get", "install", "-y", "--no-install-recommends", *packages])
def _setup_locale() -> None:
_step("Checking locale...")
result = subprocess.run(["locale"], capture_output=True, text=True)
if "UTF-8" in result.stdout:
_ok("UTF-8 locale active")
return
_run(["sudo", "apt-get", "update", "-qq"])
_apt_install("locales")
_run(["sudo", "locale-gen", "en_US.UTF-8"])
_run(["sudo", "update-locale", "LC_ALL=en_US.UTF-8", "LANG=en_US.UTF-8"])
_ok("Locale configured")
def _add_ros2_repo() -> None:
_step("Adding ROS2 apt repository...")
_run(["sudo", "apt-get", "update", "-qq"])
_apt_install("software-properties-common", "curl")
_run(["sudo", "add-apt-repository", "-y", "universe"])
if not _ROS_KEYRING.exists():
_step("Downloading ROS2 signing key...")
with tempfile.NamedTemporaryFile(delete=False, suffix=".gpg") as tmp:
tmp_path = tmp.name
try:
urllib.request.urlretrieve(_ROS_KEY_URL, tmp_path)
_run(["sudo", "cp", tmp_path, str(_ROS_KEYRING)])
finally:
os.unlink(tmp_path)
if not _ROS_SOURCES.exists():
arch = _check_output(["dpkg", "--print-architecture"])
codename = _check_output(
["bash", "-c", ". /etc/os-release && echo $UBUNTU_CODENAME"]
)
sources_line = (
f"deb [arch={arch} signed-by={_ROS_KEYRING}] "
f"http://packages.ros.org/ros2/ubuntu {codename} main\n"
)
proc = subprocess.run(
["sudo", "tee", str(_ROS_SOURCES)],
input=sources_line,
capture_output=True,
text=True,
)
if proc.returncode != 0:
raise RuntimeError(f"Failed to write {_ROS_SOURCES}")
_run(["sudo", "apt-get", "update", "-qq"])
_ok("ROS2 repository ready")
def _install_ros2_jazzy() -> None:
_step("Installing ros-jazzy-ros-base + ros-dev-tools...")
_apt_install("ros-jazzy-ros-base", "ros-dev-tools")
_ok("ROS2 Jazzy installed")
def _install_colcon() -> None:
if shutil.which("colcon"):
_ok("colcon already available")
return
_step("Installing colcon...")
_apt_install("python3-colcon-common-extensions")
_ok("colcon installed")
def _setup_shell_rc() -> None:
shell_name = Path(os.environ.get("SHELL", "/bin/bash")).name
rc = Path.home() / (".zshrc" if shell_name == "zsh" else ".bashrc")
source_line = "source /opt/ros/jazzy/setup.bash"
if rc.exists() and source_line in rc.read_text():
_ok(f"ROS2 setup already in {rc.name}")
return
with rc.open("a") as f:
f.write(f"\n# ROS2 Jazzy\n{source_line}\n")
_ok(f"Added ROS2 setup to ~/{rc.name}")
def _install_jazzy() -> None:
_console.print("\n[bold]Installing ROS2 Jazzy...[/bold]\n")
try:
_setup_locale()
_add_ros2_repo()
_install_ros2_jazzy()
_install_colcon()
_setup_shell_rc()
except (subprocess.CalledProcessError, RuntimeError) as exc:
_console.print(f"\n[red]Installation failed:[/red] {exc}")
sys.exit(1)
_console.print(
"\n[green]ROS2 Jazzy installed.[/green] "
"Restart the terminal, then run [bold]cobot local-setup[/bold] again to build."
)
def _build_project() -> None:
if not shutil.which("colcon"):
_console.print("[red]Error:[/red] colcon not found. Source ROS2 first:")
_console.print(" [bold]source /opt/ros/jazzy/setup.bash[/bold]")
sys.exit(1)
_console.print("\n[bold]Building project with colcon...[/bold]\n")
result = subprocess.run(
["colcon", "build", "--symlink-install"],
cwd=_PROJECT_DIR,
)
if result.returncode != 0:
_console.print("\n[red]Build failed.[/red]")
sys.exit(result.returncode)
_console.print("\n[green]Build complete.[/green]")
_console.print(" Activate workspace: [bold]source install/setup.bash[/bold]")
class _AskInstallJazzy(App[Optional[str]]):
CSS = SCREEN_CSS
def on_mount(self) -> None:
self.push_screen(
PickScreen(
"ROS2 not found",
"ROS2 Jazzy is not installed. Install it now?",
["Yes, install ROS2 Jazzy", "No, skip"],
"Yes, install ROS2 Jazzy",
),
self.exit,
)
class _AskDockerSetup(App[Optional[str]]):
CSS = SCREEN_CSS
def on_mount(self) -> None:
self.push_screen(
PickScreen(
"Unsupported OS",
"Ubuntu 24.04 not detected. Build a Docker image for development?",
["Yes, run docker-setup", "No, exit"],
"Yes, run docker-setup",
),
self.exit,
)
def _flow_ubuntu_without_ros() -> None:
_console.print(
"\n[yellow]Warning:[/yellow] ROS2 Jazzy is not installed "
"(/opt/ros/jazzy not found).\n"
)
answer = _AskInstallJazzy().run()
if answer is None or answer.startswith("No"):
_console.print("[yellow]Skipped ROS2 installation.[/yellow]")
return
_install_jazzy()
def _flow_not_ubuntu(args: argparse.Namespace) -> None:
_console.print(
"\n[yellow]Warning:[/yellow] Ubuntu 24.04 not detected. "
"Native build is not supported on this OS.\n"
)
answer = _AskDockerSetup().run()
if answer is None or answer.startswith("No"):
_console.print("[yellow]Exiting without changes.[/yellow]")
return
_docker_setup(args)
def register(subparsers: argparse._SubParsersAction) -> None:
p = subparsers.add_parser(
"local-setup",
help="Build the project locally (requires Ubuntu 24.04 and ROS2 Jazzy)",
)
p.set_defaults(func=run)
def run(args: argparse.Namespace) -> None:
_console.print("[bold]Checking environment...[/bold]")
if not _detect_ubuntu_2404():
_flow_not_ubuntu(args)
return
_console.print("[green] Ubuntu 24.04[/green] ✓")
if not _detect_ros2_jazzy():
_flow_ubuntu_without_ros()
return
_console.print("[green] ROS2 Jazzy[/green] ✓\n")
_build_project()
+33
View File
@@ -1,8 +1,31 @@
import argparse
from typing import Optional
from textual.app import App
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
class _AskSetupMode(App[Optional[str]]):
CSS = SCREEN_CSS
def on_mount(self) -> None:
self.push_screen(
PickScreen(
"Step 1 of 1",
"How do you want to set up the project?",
[
"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 — install ROS2 Jazzy on this machine and build with colcon",
),
self.exit,
)
def register(subparsers):
@@ -11,6 +34,16 @@ def register(subparsers):
def run(args: argparse.Namespace) -> None:
_doc_setup(args)
choice = _AskSetupMode().run()
if choice is None:
return
if choice.startswith("local"):
_local_setup(args)
else:
_docker_setup(args)
_robot_setup(args)
+7 -1
View File
@@ -67,7 +67,13 @@ class PickScreen(Screen[Optional[str]]):
self.query_one(RadioSet).focus()
def action_submit(self) -> None:
btn = self.query_one("#choices", RadioSet).pressed_button
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: