Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .github/workflows/pypi.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
name: Publish to PyPI

on:
push:
tags:
- "v*"
workflow_dispatch:

jobs:
build:
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.10"

- name: Install build dependencies
run: python -m pip install --upgrade pip build twine

- name: Build distributions
run: python -m build

- name: Check distributions
run: twine check dist/*

- name: Upload distributions
uses: actions/upload-artifact@v4
with:
name: python-dist
path: dist/

publish:
needs: build
runs-on: ubuntu-latest
permissions:
id-token: write

environment:
name: pypi
url: https://pypi.org/project/tinynav/

steps:
- name: Download distributions
uses: actions/download-artifact@v4
with:
name: python-dist
path: dist/

- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
14 changes: 13 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ build-backend = "scikit_build_core.build"

[project]
name = "tinynav"
version = "0.1.3"
version = "0.0.1"
description = "minimal implementation of navigation"
readme = "README.md"
license = { file = "LICENSE" }
requires-python = ">=3.10.12,<3.11"
authors = [{ name = "Zhenfei Yang", email = "zhenfei.yang@deepmirror.com" }]
dependencies = [
Expand All @@ -27,8 +29,18 @@ dependencies = [
"reportlab>=4.4.3",
"tabulate",
"viser",
"typer>=0.12",
"rich>=13",
]

[project.scripts]
tinynav = "tinynav.cli:main"

[project.urls]
Homepage = "https://github.com/UniflexAI/tinynav"
Repository = "https://github.com/UniflexAI/tinynav"
Issues = "https://github.com/UniflexAI/tinynav/issues"

[tool.scikit-build]
cmake.source-dir = "tinynav/cpp"
wheel.install-dir = "tinynav"
Expand Down
43 changes: 43 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import unittest
from unittest import mock

from typer.testing import CliRunner

from tinynav import cli


class TestTinyNavCli(unittest.TestCase):
def setUp(self) -> None:
self.runner = CliRunner()

def test_build_docker_run_args_navigation(self) -> None:
with mock.patch("tinynav.cli.detect_runtime_args", return_value=["--gpus", "all"]):
args = cli.build_docker_run_args("run_navigation.sh")
self.assertEqual(args[:4], ["docker", "run", "--rm", "-it"])
self.assertIn("uniflexai/tinynav:latest", args)
self.assertEqual(args[-2:], ["bash", "/tinynav/scripts/run_navigation.sh"])

def test_init_runs_env_check_then_pull(self) -> None:
order = []

def record(command, *, cwd=None):
order.append((list(command), cwd))

with mock.patch("tinynav.cli.run_checked", side_effect=record):
result = self.runner.invoke(cli.app, ["init"])

self.assertEqual(result.exit_code, 0)
self.assertEqual(order[0][0][0:2], ["bash", str(cli.SCRIPTS_DIR / "check_env.sh")])
self.assertEqual(order[1][0], ["docker", "pull", cli.DEFAULT_IMAGE])
self.assertIn("TinyNav is ready", result.stdout)

def test_run_mapping_command_dispatches_script(self) -> None:
with mock.patch("tinynav.cli.run_container_script") as run_container_script:
result = self.runner.invoke(cli.app, ["run", "mapping"])

self.assertEqual(result.exit_code, 0)
run_container_script.assert_called_once_with("run_rosbag_build_map.sh")


if __name__ == "__main__":
unittest.main()
3 changes: 3 additions & 0 deletions tinynav/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
__all__ = ["__version__"]

__version__ = "0.1.3"
197 changes: 197 additions & 0 deletions tinynav/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
from __future__ import annotations

import os
import platform
import shlex
import subprocess
from pathlib import Path
from typing import Sequence

import typer
from rich.console import Console
from rich.panel import Panel
from rich.table import Table

DEFAULT_IMAGE = "uniflexai/tinynav:latest"
REPO_ROOT = Path(__file__).resolve().parent.parent
SCRIPTS_DIR = REPO_ROOT / "scripts"
console = Console()
app = typer.Typer(
name="tinynav",
no_args_is_help=True,
add_completion=False,
help="TinyNav command line interface.",
rich_markup_mode="rich",
)
run_app = typer.Typer(no_args_is_help=True, help="Run TinyNav workflows inside the default container.")
app.add_typer(run_app, name="run")


class CliError(RuntimeError):
pass


def render_header(title: str, subtitle: str | None = None) -> None:
console.print(
Panel.fit(
subtitle or "",
title=f"[bold cyan]{title}[/bold cyan]",
border_style="cyan",
)
)


def render_success(message: str) -> None:
console.print(f"[bold green]✓[/bold green] {message}")


def render_warning(message: str) -> None:
console.print(f"[bold yellow]![/bold yellow] {message}")


def render_error(message: str) -> None:
console.print(f"[bold red]✗[/bold red] {message}")


def run_checked(command: Sequence[str], *, cwd: Path | None = None) -> None:
console.print(f"[dim]$ {shlex.join(command)}[/dim]")
subprocess.run(command, cwd=cwd, check=True)


def check_host_environment() -> None:
script = SCRIPTS_DIR / "check_env.sh"
if not script.exists():
raise CliError(f"missing environment check script: {script}")
run_checked(["bash", str(script)], cwd=REPO_ROOT)


def pull_default_image() -> None:
run_checked(["docker", "pull", DEFAULT_IMAGE], cwd=REPO_ROOT)


def detect_runtime_args() -> list[str]:
arch = platform.machine().lower()
if arch in {"aarch64", "arm64"}:
return ["--runtime", "nvidia"]
return ["--gpus", "all"]


def build_docker_run_args(script_name: str) -> list[str]:
script_path = Path("/tinynav/scripts") / script_name
return [
"docker",
"run",
"--rm",
"-it",
"--net=host",
"--ipc=host",
"--privileged",
"-e",
f"DISPLAY={os.environ.get('DISPLAY', ':0')}",
"-e",
"QT_X11_NO_MITSHM=1",
"-v",
f"{REPO_ROOT}:/tinynav",
"-v",
"/tmp/.X11-unix:/tmp/.X11-unix",
"-v",
"/etc/localtime:/etc/localtime:ro",
*detect_runtime_args(),
DEFAULT_IMAGE,
"bash",
str(script_path),
]


def run_container_script(script_name: str) -> None:
script_on_host = SCRIPTS_DIR / script_name
if not script_on_host.exists():
raise CliError(f"missing script: {script_on_host}")
run_checked(build_docker_run_args(script_name), cwd=REPO_ROOT)


def render_init_summary() -> None:
table = Table(show_header=False, box=None, pad_edge=False)
table.add_column(style="bold")
table.add_column()
table.add_row("Checks", "docker / docker daemon / GPU runtime")
table.add_row("Image", DEFAULT_IMAGE)
console.print(table)


@app.command()
def init() -> None:
"""Check host environment and pull the default TinyNav image."""
render_header("tinynav init", "Check environment, then prepare the runtime image.")
render_init_summary()

try:
console.print()
console.rule("[bold]1. Checking environment[/bold]", style="cyan")
check_host_environment()
render_success("host environment check passed")

console.print()
console.rule("[bold]2. Pulling runtime image[/bold]", style="cyan")
pull_default_image()
render_success(f"image ready: {DEFAULT_IMAGE}")

console.print()
console.print(Panel.fit("[bold green]TinyNav is ready.[/bold green]", border_style="green"))
except subprocess.CalledProcessError as exc:
render_error(f"command failed with exit code {exc.returncode}")
raise typer.Exit(exc.returncode) from exc
except CliError as exc:
render_error(str(exc))
raise typer.Exit(1) from exc


@app.command()
def example() -> None:
"""Run the rosbag example in the default TinyNav container."""
render_header("tinynav example", "Run the example workflow in Docker.")
try:
run_container_script("run_rosbag_examples.sh")
except subprocess.CalledProcessError as exc:
render_error(f"command failed with exit code {exc.returncode}")
raise typer.Exit(exc.returncode) from exc
except CliError as exc:
render_error(str(exc))
raise typer.Exit(1) from exc


@run_app.command("navigation")
def run_navigation() -> None:
"""Run online navigation."""
render_header("tinynav run navigation")
try:
run_container_script("run_navigation.sh")
except subprocess.CalledProcessError as exc:
render_error(f"command failed with exit code {exc.returncode}")
raise typer.Exit(exc.returncode) from exc
except CliError as exc:
render_error(str(exc))
raise typer.Exit(1) from exc


@run_app.command("mapping")
def run_mapping() -> None:
"""Run map building from rosbag."""
render_header("tinynav run mapping")
try:
run_container_script("run_rosbag_build_map.sh")
except subprocess.CalledProcessError as exc:
render_error(f"command failed with exit code {exc.returncode}")
raise typer.Exit(exc.returncode) from exc
except CliError as exc:
render_error(str(exc))
raise typer.Exit(1) from exc


def main(argv: Sequence[str] | None = None) -> int:
app(args=list(argv) if argv is not None else None, standalone_mode=False)
return 0


if __name__ == "__main__":
raise SystemExit(main())
Loading