From 73d74b4b7a93e51533ceec27783bc872b0ac48c7 Mon Sep 17 00:00:00 2001 From: Jason Han Date: Mon, 3 Aug 2026 14:48:46 -0700 Subject: [PATCH 1/3] feat: add unified tig-cli package Replace three variant packages with single tig-cli package: - Single 'tig' command for all VICAR tools - Backend Docker image configurable via CONTAINER_IMAGE env var - Default: ghcr.io/nasa-ammos/tig/terrain-intelligence-generator:opensource - Automatic host-to-container path translation - 42 tests, 91% coverage, all passing - CI workflows for test and PyPI publish Includes documentation: - Implementation design and plan in docs/plans/ - GeoCal integration analysis in docs/geocal-integration-status.md - Experimental GeoCal Dockerfile (non-functional, for reference) --- .github/workflows/publish.yml | 50 + .github/workflows/test.yml | 55 + .gitignore | 2 + Dockerfile.geocal | 171 +++ build-geocal-image.sh | 92 ++ docs/geocal-integration-status.md | 113 ++ .../2026-07-31-tig-cli-unified-design.md | 126 ++ docs/plans/2026-07-31-tig-cli-unified-plan.md | 1212 +++++++++++++++++ tig-cli/MANIFEST.in | 3 + tig-cli/README.md | 1 + tig-cli/pyproject.toml | 41 + tig-cli/src/tig_cli/__init__.py | 6 + tig-cli/src/tig_cli/__main__.py | 5 + tig-cli/src/tig_cli/cli.py | 50 + tig-cli/src/tig_cli/container.py | 151 ++ tig-cli/src/tig_cli/path_translator.py | 75 + tig-cli/tests/__init__.py | 0 tig-cli/tests/integration/__init__.py | 0 .../tests/integration/test_vicar_execution.py | 75 + tig-cli/tests/test_cli.py | 151 ++ tig-cli/tests/test_container.py | 198 +++ tig-cli/tests/test_path_translator.py | 107 ++ 22 files changed, 2684 insertions(+) create mode 100644 .github/workflows/publish.yml create mode 100644 .github/workflows/test.yml create mode 100644 Dockerfile.geocal create mode 100755 build-geocal-image.sh create mode 100644 docs/geocal-integration-status.md create mode 100644 docs/plans/2026-07-31-tig-cli-unified-design.md create mode 100644 docs/plans/2026-07-31-tig-cli-unified-plan.md create mode 100644 tig-cli/MANIFEST.in create mode 100644 tig-cli/README.md create mode 100644 tig-cli/pyproject.toml create mode 100644 tig-cli/src/tig_cli/__init__.py create mode 100644 tig-cli/src/tig_cli/__main__.py create mode 100644 tig-cli/src/tig_cli/cli.py create mode 100644 tig-cli/src/tig_cli/container.py create mode 100644 tig-cli/src/tig_cli/path_translator.py create mode 100644 tig-cli/tests/__init__.py create mode 100644 tig-cli/tests/integration/__init__.py create mode 100644 tig-cli/tests/integration/test_vicar_execution.py create mode 100644 tig-cli/tests/test_cli.py create mode 100644 tig-cli/tests/test_container.py create mode 100644 tig-cli/tests/test_path_translator.py diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..ee7f9d5 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,50 @@ +name: Publish tig-cli + +on: + release: + types: [published] + +jobs: + build: + name: Build distribution + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install build tools + run: python -m pip install --upgrade pip build + + - name: Build package + run: | + cd tig-cli + python -m build + + - name: Upload distribution artifacts + uses: actions/upload-artifact@v4 + with: + name: tig-cli-dist + path: tig-cli/dist/ + + publish: + name: Publish to PyPI + needs: build + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write + + steps: + - name: Download distribution artifacts + uses: actions/download-artifact@v4 + with: + name: tig-cli-dist + path: dist/ + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..94c900f --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,55 @@ +name: Test tig-cli + +on: + push: + branches: ["**"] + paths: + - "tig-cli/**" + - ".github/workflows/test.yml" + pull_request: + branches: ["**"] + paths: + - "tig-cli/**" + - ".github/workflows/test.yml" + +jobs: + test: + name: "Test Python ${{ matrix.python-version }} on ${{ matrix.os }}" + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + python-version: ["3.9", "3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + cd tig-cli + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" + + - name: Run tests + run: | + cd tig-cli + python -m pytest tests/ -v --no-cov -m "not integration" + + - name: Run tests with coverage + if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12' + run: | + cd tig-cli + python -m pytest tests/ -m "not integration" --cov=tig_cli --cov-report=xml --cov-report=term-missing + + - name: Upload coverage + if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12' + uses: codecov/codecov-action@v4 + with: + file: ./tig-cli/coverage.xml + fail_ci_if_error: false diff --git a/.gitignore b/.gitignore index 375240b..9beb0c2 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,8 @@ __pycache__/ *.egg-info/ dist/ build/ +.coverage +.pytest_cache/ # Logs *.log diff --git a/Dockerfile.geocal b/Dockerfile.geocal new file mode 100644 index 0000000..62010e6 --- /dev/null +++ b/Dockerfile.geocal @@ -0,0 +1,171 @@ +# Multi-stage Dockerfile to build GeoCal from source with patched GDAL detection +# Patches ac_gdal.m4 to use gdal-config instead of AC_RUN_IFELSE + +ARG BASE_IMAGE=ghcr.io/nasa-ammos/tig/terrain-intelligence-generator:opensource + +# ============================================================================ +# Builder stage +# ============================================================================ +FROM ${BASE_IMAGE} as builder + +# Install bzip2 and build deps +RUN dnf install -y bzip2 wget git ncompress autoconf automake libtool m4 patch && dnf clean all + +# Install micromamba +RUN curl -Ls https://micro.mamba.pm/api/micromamba/linux-64/latest | tar -xvj -C / bin/micromamba && \ + mkdir -p /opt/conda + +ENV MAMBA_ROOT_PREFIX=/opt/conda +ENV PATH="/opt/conda/bin:${PATH}" + +# Install build tools and deps via conda-forge +RUN /bin/micromamba install -y -c conda-forge \ + gxx_linux-64 \ + gcc_linux-64 \ + gfortran_linux-64 \ + make \ + cmake \ + swig \ + gdal \ + libgdal \ + boost-cpp \ + gsl \ + fftw \ + python=3.9 \ + numpy \ + scipy \ + -p /opt/conda && \ + /bin/micromamba clean -afy + +# Set conda compilers +ENV CC="/opt/conda/bin/x86_64-conda-linux-gnu-gcc" +ENV CXX="/opt/conda/bin/x86_64-conda-linux-gnu-g++" +ENV FC="/opt/conda/bin/x86_64-conda-linux-gnu-gfortran" +ENV LD_LIBRARY_PATH="/opt/conda/lib:${LD_LIBRARY_PATH}" + +WORKDIR /build + +# Build CSPICE +RUN echo "=== Building CSPICE ===" && \ + curl -k -L https://naif.jpl.nasa.gov/pub/naif/toolkit/C/PC_Linux_GCC_64bit/packages/cspice.tar.Z | uncompress | tar -x && \ + cd cspice/src/cspice && \ + sed -i 's/-c /-fPIC -c /g' mkprodct.csh && \ + ./mkprodct.csh && \ + cd ../.. && \ + mkdir -p /usr/local/cspice && \ + cp -r include lib /usr/local/cspice/ + +# Build Blitz++ +RUN echo "=== Building Blitz++ ===" && \ + wget https://github.com/blitzpp/blitz/archive/refs/tags/1.0.2.tar.gz -O blitz-1.0.2.tar.gz && \ + tar xzf blitz-1.0.2.tar.gz && \ + cd blitz-1.0.2 && \ + mkdir build && cd build && \ + cmake .. \ + -DCMAKE_INSTALL_PREFIX=/usr/local \ + -DCMAKE_CXX_STANDARD=14 \ + -DBUILD_TESTING=OFF \ + -DCMAKE_POLICY_VERSION_MINIMUM=3.5 && \ + make -j$(nproc) && \ + make install + +# Clone GeoCal and patch GDAL detection +ARG GEOCAL_VERSION=e4c3cb071f3063352bd35b3048ddad1c077e10db +RUN echo "=== Cloning GeoCal ===" && \ + git clone https://github.com/Cartography-jpl/geocal.git && \ + cd geocal && \ + git checkout ${GEOCAL_VERSION} + +# Apply patch to fix GDAL version detection (use gdal-config instead of AC_RUN_IFELSE) +RUN cd /build/geocal && \ + sed -i '81,92d' config/m4/ac_gdal.m4 && \ + sed -i '81i\ AC_MSG_CHECKING([GDAL version via gdal-config])' config/m4/ac_gdal.m4 && \ + sed -i '82i\ # Use gdal-config --version instead of runtime test' config/m4/ac_gdal.m4 && \ + sed -i '83i\ # This avoids AC_RUN_IFELSE which fails in Docker builds' config/m4/ac_gdal.m4 && \ + sed -i '84i\ if test -x "$GDAL_PREFIX/bin/gdal-config"; then' config/m4/ac_gdal.m4 && \ + sed -i '85i\ gdal_version=`$GDAL_PREFIX/bin/gdal-config --version`' config/m4/ac_gdal.m4 && \ + sed -i '86i\ fi' config/m4/ac_gdal.m4 && \ + sed -i '87i\ AC_MSG_RESULT([${gdal_version}])' config/m4/ac_gdal.m4 + +# Regenerate configure script with patched m4 +RUN cd /build/geocal && \ + ./bootstrap + +# Configure and build GeoCal +RUN cd /build/geocal && \ + mkdir build && cd build && \ + ../configure \ + --prefix=/usr/local/geocal \ + --with-spice=/usr/local/cspice \ + --with-vicar-rtl=/usr/local/vicar/dev \ + --with-blitz=/usr/local \ + --with-boost=/opt/conda \ + --with-gdal=/opt/conda \ + --without-mspi-shared \ + --without-afids \ + --without-afids-data \ + --without-carto \ + --without-hdf5 \ + --disable-static \ + --without-documentation \ + PYTHON=/opt/conda/bin/python3.9 \ + GDAL_CONFIG=/opt/conda/bin/gdal-config && \ + make -j$(nproc) && \ + make install + +# ============================================================================ +# Runtime stage +# ============================================================================ +FROM ${BASE_IMAGE} + +LABEL org.opencontainers.image.title="Terrain Intelligence Generator + GeoCal" +LABEL org.opencontainers.image.description="TIG with GeoCal geometric calibration and bundle adjustment capabilities" +LABEL org.opencontainers.image.version="5.0-geocal" +LABEL org.opencontainers.image.source="https://github.com/NASA-AMMOS/tig" + +# Install runtime deps +RUN dnf install -y bzip2 && dnf clean all + +# Install micromamba for runtime +RUN curl -Ls https://micro.mamba.pm/api/micromamba/linux-64/latest | tar -xvj -C / bin/micromamba && \ + mkdir -p /opt/conda + +ENV MAMBA_ROOT_PREFIX=/opt/conda +ENV PATH="/opt/conda/bin:${PATH}" + +# Install runtime libs from conda-forge +RUN /bin/micromamba install -y -c conda-forge \ + gdal \ + boost-cpp \ + gsl \ + fftw \ + python=3.9 \ + numpy \ + scipy \ + matplotlib \ + -p /opt/conda && \ + /bin/micromamba clean -afy + +# Copy built artifacts from builder +COPY --from=builder /usr/local/cspice /usr/local/cspice +COPY --from=builder /usr/local/include/blitz /usr/local/include/blitz +COPY --from=builder /usr/local/lib/libblitz.* /usr/local/lib/ +COPY --from=builder /usr/local/geocal /usr/local/geocal + +# Set up environment +ENV PATH="/opt/conda/bin:/usr/local/geocal/bin:${PATH}" +ENV LD_LIBRARY_PATH="/opt/conda/lib:/usr/local/geocal/lib:/usr/local/lib:${LD_LIBRARY_PATH}" +ENV PYTHONPATH="/usr/local/geocal/lib/python3.9/site-packages:${PYTHONPATH}" + +# Update library cache +RUN ldconfig + +# Verify +RUN echo "=== VICAR RTL ===" && \ + ls -la /usr/local/vicar/dev/p2/lib/x86-64-linx/*.a | head -3 && \ + echo "=== GeoCal ===" && \ + ls -la /usr/local/geocal/lib/ | head -10 + +WORKDIR /work + +CMD ["/bin/bash"] diff --git a/build-geocal-image.sh b/build-geocal-image.sh new file mode 100755 index 0000000..10d1e9e --- /dev/null +++ b/build-geocal-image.sh @@ -0,0 +1,92 @@ +#!/bin/bash +# Build script for TIG+GeoCal Docker image + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DOCKER_FILE="$SCRIPT_DIR/Dockerfile.geocal" + +# Default values +IMAGE_NAME="tig" +IMAGE_TAG="geocal" +GEOCAL_VERSION="e4c3cb071f3063352bd35b3048ddad1c077e10db" +PUSH_IMAGE=false + +# Parse arguments +while [[ $# -gt 0 ]]; do + case $1 in + --name) + IMAGE_NAME="$2" + shift 2 + ;; + --tag) + IMAGE_TAG="$2" + shift 2 + ;; + --geocal-version) + GEOCAL_VERSION="$2" + shift 2 + ;; + --push) + PUSH_IMAGE=true + shift + ;; + *) + echo "Unknown option: $1" + echo "Usage: $0 [--name IMAGE_NAME] [--tag IMAGE_TAG] [--geocal-version VERSION] [--push]" + exit 1 + ;; + esac +done + +FULL_IMAGE_NAME="${IMAGE_NAME}:${IMAGE_TAG}" + +echo "" +echo "╔════════════════════════════════════════════════════╗" +echo "║ Building TIG+GeoCal Image ║" +echo "╚════════════════════════════════════════════════════╝" +echo "" +echo "Image: ${FULL_IMAGE_NAME}" +echo "GeoCal version: ${GEOCAL_VERSION}" +echo "Push: ${PUSH_IMAGE}" +echo "" + +# Check if base image exists +echo -e "\e[32m✓\e[0m Checking for base TIG image..." +if ! docker image inspect ghcr.io/nasa-ammos/tig/terrain-intelligence-generator:opensource &> /dev/null; then + echo -e "\e[31m✗\e[0m Base image not found. Pulling..." + docker pull ghcr.io/nasa-ammos/tig/terrain-intelligence-generator:opensource +fi + +# Build the image +echo -e "\e[32m✓\e[0m Starting build (this will take 45-60 minutes)..." +echo "" + +if docker build \ + -f "${DOCKER_FILE}" \ + -t "${FULL_IMAGE_NAME}" \ + --build-arg GEOCAL_VERSION="${GEOCAL_VERSION}" \ + "$SCRIPT_DIR"; then + echo "" + echo -e "\e[32m✓\e[0m Build successful: ${FULL_IMAGE_NAME}" + + # Show image size + IMAGE_SIZE=$(docker images "${FULL_IMAGE_NAME}" --format "{{.Size}}") + echo -e "\e[32m✓\e[0m Image size: ${IMAGE_SIZE}" + + # Push if requested + if [ "$PUSH_IMAGE" = true ]; then + echo -e "\e[32m✓\e[0m Pushing image..." + docker push "${FULL_IMAGE_NAME}" + echo -e "\e[32m✓\e[0m Push complete" + fi + + echo "" + echo "To run the image:" + echo " docker run -it --rm ${FULL_IMAGE_NAME}" + echo "" +else + echo "" + echo -e "\e[31m✗\e[0m Build failed" + exit 1 +fi diff --git a/docs/geocal-integration-status.md b/docs/geocal-integration-status.md new file mode 100644 index 0000000..3fa87a4 --- /dev/null +++ b/docs/geocal-integration-status.md @@ -0,0 +1,113 @@ +# GeoCal Integration Status + +## Overview +This document tracks the status of integrating [GeoCal](https://github.com/Cartography-jpl/geocal) (geometric calibration and bundle adjustment) into the TIG environment. + +## Background +- **GeoCal**: Part of the AFIDS (Automated Feature Identification for Downlink System) cartography suite from JPL +- **Purpose**: Advanced geometric calibration, bundle adjustment, camera modeling for planetary/Earth imaging missions +- **Relationship to TIG**: Both systems use VICAR as foundation; GeoCal adds modern geometric processing capabilities + +## Current Status: **Build Complexity Blockers** + +### Attempted Approaches + +#### 1. ✗ Pre-built Conda Package +**Attempt**: Install `geocal` from conda-forge +**Result**: Package does not exist in conda-forge +**Notes**: AFIDS/GeoCal not published to public conda channels + +#### 2. ✗ Build from Source (gcc-toolset-10) +**Attempt**: Install gcc-toolset-10 on Oracle Linux 8 for C++17 support +**Result**: Build hung during 113MB+ toolset installation +**Blocker**: Resource constraints, long build time + +#### 3. ✗ Build from Source (micromamba + conda-forge compilers) +**Attempt**: Use micromamba + conda-forge's gxx_linux-64 for C++17 compiler +**Result**: Successfully built CSPICE and Blitz++, but GeoCal configure failed +**Blocker**: GeoCal's `configure.ac` has brittle GDAL version detection that fails with conda-installed GDAL: +``` +checking version GDAL library is new enough... no +configure: error: Need to have GDAL >= 1.9.2 +``` +Even with GDAL 3.x installed and explicit `GDAL_CONFIG` path provided. + +### Technical Challenges +1. **C++17 Requirement**: Oracle Linux 8 base has gcc 8.5 (too old), requires modern compiler +2. **Complex Dependencies**: CSPICE, Blitz++, Boost, GDAL, HDF5, VICAR RTL +3. **Autoconf Brittleness**: GeoCal's configure script has fragile dependency detection +4. **No Pre-built Binaries**: No official releases or conda packages available +5. **Build Time**: Multi-hour build expected (~45-60 min estimate was optimistic) + +## Recommended Path Forward + +### Option A: Wait for Upstream Improvements +- Request JPL/Cartography team publish conda packages or Docker images +- Wait for geocal CMake migration (more robust than autoconf) +- Track https://github.com/Cartography-jpl/geocal/issues + +### Option B: Separate GeoCal Environment +Rather than integrating into TIG base image: +1. Create separate `tig-geocal-dev` image based on conda-forge/miniforge3 +2. Install GeoCal from source with conda build tools +3. Install VICAR from TIG's pre-built binaries (extract from TIG image) +4. Link the two: use TIG for VICAR terrain processing, GeoCal for calibration +5. Provide data exchange scripts between environments + +### Option C: Minimal GeoCal Build +Focus on subset of GeoCal functionality: +1. Build only core geocal library (no Python wrappers initially) +2. Skip GDAL integration for first pass (limits functionality but avoids configure issues) +3. Use as C++ library only, called from VICAR programs +4. Expand gradually as build issues resolved + +### Option D: Document Integration for Users +Provide instructions for users to: +1. Clone geocal repo +2. Build locally (with detailed troubleshooting guide) +3. Mount into TIG container at runtime via `-v` bind mount +4. Set environment variables to link TIG VICAR + user's GeoCal + +## Files Created +- `Dockerfile.geocal` - Multi-stage build attempt (incomplete/non-functional) +- `build-geocal-image.sh` - Build script (not tested end-to-end) +- `docs/geocal-integration.md` - Original integration documentation (optimistic) +- `docs/geocal-integration-status.md` - This status document + +## Dependencies for Reference +From afids-conda-package analysis: +```yaml +# Core deps +- cspice >=N0067 # NASA SPICE toolkit +- blitz >=1.0.2 # C++ array library +- boost-cpp +- gdal >=3.0 +- hdf5 +- gsl +- fftw +- sqlite + +# Python deps +- python >=3.9 +- numpy +- scipy +- matplotlib +- pytest + +# Build deps +- cmake >=3.18 +- swig +- gcc >=11 (C++17) +- gfortran +``` + +## Next Steps +**Decision needed**: Which option (A, B, C, or D) to pursue? + +**Recommendation**: Option B (Separate GeoCal Environment) +- Cleanest separation of concerns +- TIG stays lean and focused on VICAR terrain processing +- GeoCal environment can evolve independently +- Data exchange via files (natural boundary for both systems) +- Users who need both get both; users who only need TIG aren't burdened + diff --git a/docs/plans/2026-07-31-tig-cli-unified-design.md b/docs/plans/2026-07-31-tig-cli-unified-design.md new file mode 100644 index 0000000..04b4944 --- /dev/null +++ b/docs/plans/2026-07-31-tig-cli-unified-design.md @@ -0,0 +1,126 @@ +# TIG CLI Unified Design + +**Date:** 2026-07-31 +**Branch:** feature/tig-cli-unified +**Status:** Approved + +## Summary + +Replace three pip packages (`tig-cli-core`, `tig-opensource`, `tig-m20-g87`) with a single `tig-cli` package that installs one command: `tig`. The Docker image used as the backend is selected via the `CONTAINER_IMAGE` environment variable; default is the opensource image. + +## Motivation + +The original design had separate packages per variant (one for open-source, one for M20 G87). These were never published. A single package with env-var image selection is simpler to install, simpler to document, and sufficient for all use cases. + +## Package Structure + +| Old | New | +|-----|-----| +| `tig-cli-core/` | deleted | +| `tig-opensource/` | deleted | +| `tig-m20-g87/` | deleted | +| *(new)* | `tig-cli/` | + +### `tig-cli/` + +``` +tig-cli/ +├── pyproject.toml # package name: tig-cli, entrypoint: tig = tig_cli.cli:main +├── MANIFEST.in +├── README.md +└── src/ + └── tig_cli/ + ├── __init__.py + ├── container.py # ContainerManager + get_container_image() + ├── path_translator.py # unchanged + └── cli.py # single main() function +tests/ +├── test_path_translator.py +├── test_container.py +├── test_cli.py +└── integration/ + └── test_vicar_execution.py +``` + +## CLI Interface + +``` +tig [args...] +``` + +**Options:** +- `--writable-path PATH` — mount additional host dir read-write inside container (repeatable) +- `--disable-path-translation` — skip automatic host→container path rewriting + +**Help text** shows the active image (resolved from `CONTAINER_IMAGE` or default). + +**No `--variant` or `--image` flag.** Image selection is env-var only. + +## Image Configuration + +```python +DEFAULT_IMAGE = "ghcr.io/nasa-ammos/tig/terrain-intelligence-generator:opensource" + +def get_container_image() -> str: + return os.environ.get("CONTAINER_IMAGE", DEFAULT_IMAGE) +``` + +`CONTAINER_IMAGE` accepts any valid Docker image reference (full URI including registry, repo, and tag). No short-name resolution — users set the full image string. + +**Examples:** +```bash +# default (opensource) +tig marsmap ... + +# proprietary variant +CONTAINER_IMAGE=ghcr.io/nasa-ammos/tig/terrain-intelligence-generator:m20-g87 tig marsmap ... + +# custom/local image +CONTAINER_IMAGE=my-org/custom-vicar:v2 tig marsmap ... +``` + +## Code Changes + +### `container.py` + +- `ContainerManager.__init__` signature changes from `(variant: VariantConfig, ...)` to `(image: str, ...)` +- Add `get_container_image() -> str` module-level function (reads `CONTAINER_IMAGE` env var) +- Container name prefix: fixed string `tig-vicar` (no variant-derived name) +- All other logic (mounts, lifecycle, exec, path translation) unchanged + +### `cli.py` + +- Remove `create_cli(variant_name)` factory pattern +- Single `main()` function decorated with `@click.command` +- Calls `get_container_image()` at invocation time +- Help text includes: `f"Active image: {get_container_image()}"` + +### `variants.py` + +- Deleted entirely. `VariantConfig` dataclass and `VARIANTS` registry removed. + +### `__init__.py` + +- Remove any variant-related imports + +## Testing + +- **Delete** `test_variants.py` +- **Update** `test_container.py`: replace `VariantConfig` fixtures with direct `image` string; mock `CONTAINER_IMAGE` env var via `monkeypatch.setenv` +- **Update** `test_cli.py`: test `main()` directly; test env var override; test default image +- `test_path_translator.py` — unchanged +- Integration tests — unchanged + +## CI / Publishing + +- `.github/workflows/test.yml` — update paths to `tig-cli/` +- `.github/workflows/publish.yml` — update to publish single `tig-cli` package +- PyPI package name: `tig-cli` +- Entrypoint command: `tig` + +## What Is Not Changing + +- Path translation logic (`path_translator.py`) — unchanged +- Container mount strategy (root ro + home rw + writable paths) — unchanged +- `--writable-path` and `--disable-path-translation` flags — unchanged +- Container lifecycle (ephemeral: start → exec → stop per invocation) — unchanged diff --git a/docs/plans/2026-07-31-tig-cli-unified-plan.md b/docs/plans/2026-07-31-tig-cli-unified-plan.md new file mode 100644 index 0000000..4cb4762 --- /dev/null +++ b/docs/plans/2026-07-31-tig-cli-unified-plan.md @@ -0,0 +1,1212 @@ +# TIG CLI Unified Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace three pip packages (`tig-cli-core`, `tig-opensource`, `tig-m20-g87`) with a single `tig-cli` package exposing one `tig` command, with backend Docker image configurable via `CONTAINER_IMAGE` env var. + +**Architecture:** Single `tig-cli/` directory containing all source, tests, and packaging config. `container.py` reads `CONTAINER_IMAGE` at invocation time with a hardcoded opensource default. `variants.py` deleted. `cli.py` becomes a single `main()` function. + +**Tech Stack:** Python 3.9+, Click 8.0+, docker-py 6.0+, pytest, setuptools + +--- + +## Outline + +- Task 1: Create `tig-cli/` package scaffold +- Task 2: Write `path_translator.py` (copy unchanged, verify tests pass) +- Task 3: Write `container.py` (remove VariantConfig, add `get_container_image()`) +- Task 4: Write `cli.py` (single `main()`, show active image in help) +- Task 5: Update tests (remove variant tests, update container/cli tests) +- Task 6: Update CI workflows +- Task 7: Delete old packages +- Task 8: Verify full test suite passes + +--- + +## Task 1: Create `tig-cli/` package scaffold + +**Files:** +- Create: `tig-cli/pyproject.toml` +- Create: `tig-cli/MANIFEST.in` +- Create: `tig-cli/src/tig_cli/__init__.py` +- Create: `tig-cli/tests/__init__.py` +- Create: `tig-cli/tests/integration/__init__.py` + +- [ ] **Step 1: Create directory structure** + +```bash +mkdir -p tig-cli/src/tig_cli +mkdir -p tig-cli/tests/integration +touch tig-cli/tests/__init__.py +touch tig-cli/tests/integration/__init__.py +``` + +- [ ] **Step 2: Write `tig-cli/pyproject.toml`** + +```toml +[project] +name = "tig-cli" +version = "0.1.0" +description = "TIG CLI for running VICAR commands via Docker" +requires-python = ">=3.9" +dependencies = [ + "click>=8.0.0", + "docker>=6.0.0", +] +authors = [ + {name = "NASA AMMOS", email = "ammos@jpl.nasa.gov"} +] +readme = "README.md" +license = {text = "Apache-2.0"} + +[project.scripts] +tig = "tig_cli.cli:main" + +[project.optional-dependencies] +dev = [ + "pytest>=7.0.0", + "pytest-cov>=4.0.0", + "pytest-mock>=3.10.0", +] + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = "-v --cov=tig_cli --cov-report=term-missing" +markers = [ + "integration: marks tests as integration tests (deselect with '-m \"not integration\"')", +] +``` + +- [ ] **Step 3: Write `tig-cli/MANIFEST.in`** + +``` +include README.md +include LICENSE +recursive-include src/tig_cli *.py +``` + +- [ ] **Step 4: Write `tig-cli/src/tig_cli/__init__.py`** + +```python +"""TIG CLI — run VICAR commands via Docker. + +Configure the backend image with the CONTAINER_IMAGE environment variable. +""" + +__version__ = "0.1.0" +``` + +- [ ] **Step 5: Install package in editable mode** + +```bash +cd tig-cli +pip install -e ".[dev]" +``` + +Expected: successful install with no errors. + +- [ ] **Step 6: Commit** + +```bash +git add tig-cli/ +git commit -m "feat: scaffold tig-cli package" +``` + +--- + +## Task 2: Copy `path_translator.py` and its tests + +**Files:** +- Create: `tig-cli/src/tig_cli/path_translator.py` +- Create: `tig-cli/tests/test_path_translator.py` + +- [ ] **Step 1: Write `tig-cli/src/tig_cli/path_translator.py`** + +This file is identical to the old `tig-cli-core` version — no logic changes. + +```python +"""Path translation for host-to-container path mapping.""" +import os +from pathlib import Path +from typing import List + + +class PathTranslator: + """Translates host paths to container paths. + + Handles the mapping between host filesystem paths and their + corresponding paths inside the container: + - Relative paths: unchanged + - Home directory paths: unchanged (mounted directly) + - Other absolute paths: prefixed with /host + """ + + def __init__(self, home: str): + """Initialize the path translator. + + Args: + home: Home directory path (typically os.environ["HOME"]) + """ + self.home = Path(home).resolve() + + def translate(self, path: str) -> str: + """Translate a single path from host to container. + + Args: + path: Host filesystem path + + Returns: + Container filesystem path + """ + if not path: + return path + + # Relative path - no translation + if not os.path.isabs(path): + return path + + resolved = Path(path).resolve() + + # Home directory - mounted directly at same path + if resolved.is_relative_to(self.home): + return str(resolved) + + # Absolute path outside home - add /host prefix + return f"/host{resolved}" + + def translate_args(self, args: List[str]) -> List[str]: + """Translate a list of arguments. + + Args: + args: List of command arguments (may contain paths) + + Returns: + List of arguments with paths translated + """ + return [self.translate(arg) for arg in args] + + def get_container_cwd(self, host_cwd: str) -> str: + """Map host CWD to container CWD. + + Args: + host_cwd: Host current working directory + + Returns: + Container working directory path + """ + cwd = Path(host_cwd).resolve() + + if cwd.is_relative_to(self.home): + return str(cwd) + + return f"/host{cwd}" +``` + +- [ ] **Step 2: Write `tig-cli/tests/test_path_translator.py`** + +```python +"""Tests for path translation.""" +import os +from pathlib import Path +import pytest +from tig_cli.path_translator import PathTranslator + + +@pytest.fixture +def home_dir(tmp_path): + """Create a temporary home directory.""" + return str(tmp_path / "home" / "user") + + +@pytest.fixture +def translator(home_dir): + """Create a PathTranslator instance.""" + return PathTranslator(home_dir) + + +def test_relative_path_unchanged(translator): + assert translator.translate("file.vic") == "file.vic" + assert translator.translate("./data/file.vic") == "./data/file.vic" + assert translator.translate("../other/file.vic") == "../other/file.vic" + + +def test_home_path_unchanged(translator, home_dir): + path = f"{home_dir}/data/file.vic" + assert translator.translate(path) == path + + +def test_system_path_gets_host_prefix(translator): + assert translator.translate("/data/file.vic") == "/host/data/file.vic" + assert translator.translate("/tmp/output.vic") == "/host/tmp/output.vic" + + +def test_empty_path_unchanged(translator): + assert translator.translate("") == "" + + +def test_translate_args_list(translator, home_dir): + args = [ + "file.vic", + f"{home_dir}/input.vic", + "/data/system.vic", + ] + expected = [ + "file.vic", + f"{home_dir}/input.vic", + "/host/data/system.vic", + ] + assert translator.translate_args(args) == expected + + +def test_get_container_cwd_in_home(translator, home_dir): + cwd = f"{home_dir}/projects/vicar" + assert translator.get_container_cwd(cwd) == cwd + + +def test_get_container_cwd_outside_home(translator): + cwd = "/opt/vicar/workspace" + assert translator.get_container_cwd(cwd) == "/host/opt/vicar/workspace" + + +def test_home_directory_itself(translator, home_dir): + assert translator.translate(home_dir) == home_dir + + +def test_root_path_gets_host_prefix(translator): + assert translator.translate("/") == "/host/" + + +def test_path_with_spaces(translator, home_dir): + path = f"{home_dir}/my documents/file.vic" + assert translator.translate(path) == path + system_path = "/data/my files/image.vic" + assert translator.translate(system_path) == "/host/data/my files/image.vic" + + +def test_path_with_special_characters(translator): + assert translator.translate("/data/file-name.vic") == "/host/data/file-name.vic" + assert translator.translate("/data/file_name.vic") == "/host/data/file_name.vic" + assert translator.translate("/data/file.name.vic") == "/host/data/file.name.vic" + + +def test_non_path_arguments(translator): + assert translator.translate("123") == "123" + assert translator.translate("3.14") == "3.14" + assert translator.translate("-v") == "-v" + assert translator.translate("--verbose") == "--verbose" + assert translator.translate("INP=file.vic") == "INP=file.vic" + assert translator.translate("OUT=/tmp/out.vic") == "OUT=/tmp/out.vic" + + +def test_translate_args_mixed_types(translator, home_dir): + args = [ + "marsmap", + "-v", + f"{home_dir}/input.vic", + "/data/system.vic", + "output.vic", + "SIZE=(1,1,1024,1024)", + ] + result = translator.translate_args(args) + assert result[0] == "marsmap" + assert result[1] == "-v" + assert result[2] == f"{home_dir}/input.vic" + assert result[3] == "/host/data/system.vic" + assert result[4] == "output.vic" + assert result[5] == "SIZE=(1,1,1024,1024)" +``` + +- [ ] **Step 3: Run tests to verify they pass** + +```bash +cd tig-cli +pytest tests/test_path_translator.py -v +``` + +Expected: all 14 tests PASS. + +- [ ] **Step 4: Commit** + +```bash +git add tig-cli/src/tig_cli/path_translator.py tig-cli/tests/test_path_translator.py +git commit -m "feat: add path translator to tig-cli" +``` + +--- + +## Task 3: Write `container.py` + +**Files:** +- Create: `tig-cli/src/tig_cli/container.py` +- Create: `tig-cli/tests/test_container.py` + +Key changes from old version: +- Remove `VariantConfig` import +- Add `get_container_image()` function reading `CONTAINER_IMAGE` env var +- `ContainerManager.__init__` takes `image: str` instead of `variant: VariantConfig` +- Container name: `tig-vicar-{pid}` (fixed prefix, no variant-derived name) + +- [ ] **Step 1: Write failing tests first** + +```python +# tig-cli/tests/test_container.py +"""Tests for container management.""" +import os +import pytest +from unittest.mock import Mock, patch, MagicMock +from tig_cli.container import ContainerManager, get_container_image + +DEFAULT_IMAGE = "ghcr.io/nasa-ammos/tig/terrain-intelligence-generator:opensource" + + +# --- get_container_image --- + +def test_get_container_image_default(): + """Returns default image when CONTAINER_IMAGE not set.""" + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("CONTAINER_IMAGE", None) + assert get_container_image() == DEFAULT_IMAGE + + +def test_get_container_image_from_env(): + """Returns value of CONTAINER_IMAGE env var.""" + custom = "ghcr.io/my-org/custom-vicar:v2" + with patch.dict(os.environ, {"CONTAINER_IMAGE": custom}): + assert get_container_image() == custom + + +# --- ContainerManager init --- + +@pytest.fixture +def home_dir(tmp_path): + return str(tmp_path / "home" / "user") + + +def test_container_manager_init(home_dir): + """ContainerManager initializes with image string.""" + with patch('tig_cli.container.docker.from_env'), \ + patch.dict(os.environ, {"HOME": home_dir}): + manager = ContainerManager("test-image:latest") + assert manager.image == "test-image:latest" + assert manager.container_name.startswith("tig-vicar-") + + +def test_container_manager_default_no_translation(home_dir): + """Path translation enabled by default.""" + with patch('tig_cli.container.docker.from_env'), \ + patch.dict(os.environ, {"HOME": home_dir}): + manager = ContainerManager("test-image:latest") + assert manager.disable_path_translation is False + + +# --- _build_volume_mounts --- + +def test_build_volume_mounts_basic(home_dir): + with patch('tig_cli.container.docker.from_env'), \ + patch.dict(os.environ, {"HOME": home_dir}): + manager = ContainerManager("test-image:latest") + volumes = manager._build_volume_mounts([]) + + assert "/" in volumes + assert volumes["/"]["bind"] == "/host" + assert volumes["/"]["mode"] == "ro" + assert home_dir in volumes + assert volumes[home_dir]["bind"] == home_dir + assert volumes[home_dir]["mode"] == "rw" + + +def test_build_volume_mounts_with_writable_paths(home_dir, tmp_path): + writable_path = str(tmp_path / "data") + os.makedirs(writable_path, exist_ok=True) + + with patch('tig_cli.container.docker.from_env'), \ + patch.dict(os.environ, {"HOME": home_dir}): + manager = ContainerManager("test-image:latest") + volumes = manager._build_volume_mounts([writable_path]) + + assert writable_path in volumes + assert volumes[writable_path]["bind"] == f"/host{writable_path}" + assert volumes[writable_path]["mode"] == "rw" + + +def test_build_volume_mounts_skips_nonexistent_paths(home_dir): + with patch('tig_cli.container.docker.from_env'), \ + patch.dict(os.environ, {"HOME": home_dir}): + manager = ContainerManager("test-image:latest") + volumes = manager._build_volume_mounts(["/nonexistent/path"]) + + assert len(volumes) == 2 + + +# --- start_container --- + +@patch('tig_cli.container.docker.from_env') +def test_start_container_linux(mock_docker, home_dir): + mock_client = MagicMock() + mock_docker.return_value = mock_client + + with patch.dict(os.environ, {"HOME": home_dir, "DISPLAY": ":0"}): + manager = ContainerManager("test-image:latest") + with patch('sys.platform', 'linux'): + manager.start_container([]) + + call_kwargs = mock_client.containers.run.call_args[1] + assert call_kwargs['image'] == "test-image:latest" + assert call_kwargs['detach'] is True + assert call_kwargs['network_mode'] == 'host' + assert 'DISPLAY' in call_kwargs['environment'] + + +@patch('tig_cli.container.docker.from_env') +def test_start_container_macos(mock_docker, home_dir): + mock_client = MagicMock() + mock_docker.return_value = mock_client + + with patch.dict(os.environ, {"HOME": home_dir}): + manager = ContainerManager("test-image:latest") + with patch('sys.platform', 'darwin'): + manager.start_container([]) + + call_kwargs = mock_client.containers.run.call_args[1] + assert call_kwargs['environment']['DISPLAY'] == 'host.docker.internal:0' + assert 'network_mode' not in call_kwargs + + +# --- stop_container --- + +@patch('tig_cli.container.docker.from_env') +def test_stop_container(mock_docker, home_dir): + mock_client = MagicMock() + mock_container = MagicMock() + mock_docker.return_value = mock_client + + with patch.dict(os.environ, {"HOME": home_dir}): + manager = ContainerManager("test-image:latest") + manager.container = mock_container + manager.stop_container() + + mock_container.stop.assert_called_once() + mock_container.remove.assert_called_once() + + +# --- execute_vicar_command --- + +@patch('tig_cli.container.subprocess.run') +def test_execute_vicar_command(mock_run, home_dir): + mock_run.return_value = Mock(returncode=0) + + with patch('tig_cli.container.docker.from_env'), \ + patch.dict(os.environ, {"HOME": home_dir}): + manager = ContainerManager("test-image:latest") + with patch('os.getcwd', return_value=f"{home_dir}/projects"): + exit_code = manager.execute_vicar_command("marsmap", ["input.vic", "output.vic"]) + + assert exit_code == 0 + call_args = mock_run.call_args[0][0] + assert call_args[0] == "docker" + assert call_args[1] == "exec" + assert "marsmap" in call_args + assert "input.vic" in call_args + assert "output.vic" in call_args + + +@patch('tig_cli.container.subprocess.run') +def test_execute_vicar_command_with_path_translation(mock_run, home_dir): + mock_run.return_value = Mock(returncode=0) + + with patch('tig_cli.container.docker.from_env'), \ + patch.dict(os.environ, {"HOME": home_dir}): + manager = ContainerManager("test-image:latest") + with patch('os.getcwd', return_value=f"{home_dir}/projects"): + manager.execute_vicar_command( + "marsmap", + ["/data/input.vic", f"{home_dir}/output.vic"] + ) + + call_args = mock_run.call_args[0][0] + assert "/host/data/input.vic" in call_args + assert f"{home_dir}/output.vic" in call_args + + +@patch('tig_cli.container.subprocess.run') +def test_execute_vicar_command_without_translation(mock_run, home_dir): + mock_run.return_value = Mock(returncode=0) + + with patch('tig_cli.container.docker.from_env'), \ + patch.dict(os.environ, {"HOME": home_dir}): + manager = ContainerManager("test-image:latest", disable_path_translation=True) + with patch('os.getcwd', return_value=f"{home_dir}/projects"): + manager.execute_vicar_command("marsmap", ["/data/input.vic"]) + + call_args = mock_run.call_args[0][0] + assert "/data/input.vic" in call_args + assert "/host/data/input.vic" not in call_args +``` + +- [ ] **Step 2: Run tests — expect ImportError (module not written yet)** + +```bash +cd tig-cli +pytest tests/test_container.py -v 2>&1 | head -20 +``` + +Expected: `ImportError` or `ModuleNotFoundError` for `tig_cli.container`. + +- [ ] **Step 3: Write `tig-cli/src/tig_cli/container.py`** + +```python +"""Container lifecycle management.""" +import os +import subprocess +import sys +from pathlib import Path +from typing import List, Dict, Any, Optional +import docker + +from .path_translator import PathTranslator + +DEFAULT_IMAGE = "ghcr.io/nasa-ammos/tig/terrain-intelligence-generator:opensource" + + +def get_container_image() -> str: + """Return the Docker image to use for VICAR execution. + + Reads CONTAINER_IMAGE environment variable. Falls back to the + opensource image if not set. + """ + return os.environ.get("CONTAINER_IMAGE", DEFAULT_IMAGE) + + +class ContainerManager: + """Manages VICAR container lifecycle and execution. + + Handles starting containers with appropriate mounts, + executing VICAR commands, and cleanup. + """ + + def __init__( + self, + image: str, + disable_path_translation: bool = False + ): + """Initialize the container manager. + + Args: + image: Docker image name and tag + disable_path_translation: Skip path translation (for debugging) + """ + self.image = image + self.disable_path_translation = disable_path_translation + self.client = docker.from_env() + self.container_name = f"tig-vicar-{os.getpid()}" + self.translator = PathTranslator(os.environ["HOME"]) + self.container: Optional[Any] = None + + def _build_volume_mounts( + self, + writable_paths: List[str] + ) -> Dict[str, Dict[str, str]]: + """Build volume mount configuration. + + Args: + writable_paths: Additional paths to mount as read-write + + Returns: + Dictionary of volume mounts for docker-py + """ + home = os.environ["HOME"] + + volumes = { + "/": {"bind": "/host", "mode": "ro"}, + home: {"bind": home, "mode": "rw"}, + } + + for path in writable_paths: + if os.path.isdir(path): + volumes[path] = {"bind": f"/host{path}", "mode": "rw"} + + return volumes + + def start_container(self, writable_paths: List[str]) -> None: + """Start the VICAR container with appropriate mounts. + + Args: + writable_paths: Additional paths to mount as read-write + """ + volumes = self._build_volume_mounts(writable_paths) + + environment = {} + extra_kwargs = {} + + if sys.platform == "darwin": + environment["DISPLAY"] = "host.docker.internal:0" + else: + environment["DISPLAY"] = os.environ.get("DISPLAY", ":0") + volumes["/tmp/.X11-unix"] = {"bind": "/tmp/.X11-unix", "mode": "rw"} + extra_kwargs["network_mode"] = "host" + + self.container = self.client.containers.run( + image=self.image, + name=self.container_name, + volumes=volumes, + environment=environment, + detach=True, + command="tail -f /dev/null", + **extra_kwargs + ) + + def stop_container(self) -> None: + """Stop and remove the container.""" + if self.container: + self.container.stop() + self.container.remove() + + def execute_vicar_command( + self, + vicar_tool: str, + args: List[str] + ) -> int: + """Execute a VICAR command in the container. + + Args: + vicar_tool: VICAR tool name (e.g., "marsmap", "label") + args: Command arguments + + Returns: + Exit code from command execution + """ + if self.disable_path_translation: + translated_args = args + else: + translated_args = self.translator.translate_args(args) + + container_cwd = self.translator.get_container_cwd(os.getcwd()) + + exec_args = [ + "docker", "exec", + "-w", container_cwd, + "-e", "XFILESEARCHPATH=/usr/local/vicar/gui/%N", + "-e", "XBMLANGPATH=/usr/local/vicar/gui/%L", + self.container_name, + vicar_tool, + *translated_args + ] + + result = subprocess.run(exec_args) + return result.returncode +``` + +- [ ] **Step 4: Run tests — expect all pass** + +```bash +cd tig-cli +pytest tests/test_container.py -v +``` + +Expected: all 14 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add tig-cli/src/tig_cli/container.py tig-cli/tests/test_container.py +git commit -m "feat: add container manager to tig-cli (image via env var)" +``` + +--- + +## Task 4: Write `cli.py` + +**Files:** +- Create: `tig-cli/src/tig_cli/cli.py` +- Create: `tig-cli/tests/test_cli.py` + +Key changes from old version: +- Remove `create_cli(variant_name)` factory; single `main()` function +- Call `get_container_image()` at invocation time +- Help text shows active image + +- [ ] **Step 1: Write failing tests first** + +```python +# tig-cli/tests/test_cli.py +"""Tests for the tig CLI.""" +import os +import pytest +from click.testing import CliRunner +from unittest.mock import patch, MagicMock +from tig_cli.cli import main +from tig_cli.container import DEFAULT_IMAGE + + +@pytest.fixture +def runner(): + return CliRunner() + + +def test_help_shows_active_image_default(runner): + """Help text includes active image (default).""" + result = runner.invoke(main, ['--help']) + assert result.exit_code == 0 + assert DEFAULT_IMAGE in result.output + + +def test_help_shows_active_image_from_env(runner): + """Help text includes active image from CONTAINER_IMAGE env var.""" + custom = "my-org/custom-vicar:v2" + with patch.dict(os.environ, {"CONTAINER_IMAGE": custom}): + result = runner.invoke(main, ['--help']) + assert result.exit_code == 0 + assert custom in result.output + + +@patch('tig_cli.cli.ContainerManager') +def test_cli_executes_vicar_command(mock_manager_class, runner): + """CLI starts container, executes command, stops container.""" + mock_manager = MagicMock() + mock_manager.execute_vicar_command.return_value = 0 + mock_manager_class.return_value = mock_manager + + result = runner.invoke(main, ['marsmap', 'input.vic', 'output.vic']) + + assert result.exit_code == 0 + mock_manager.start_container.assert_called_once() + mock_manager.execute_vicar_command.assert_called_once_with( + 'marsmap', ['input.vic', 'output.vic'] + ) + mock_manager.stop_container.assert_called_once() + + +@patch('tig_cli.cli.ContainerManager') +def test_cli_uses_container_image_env_var(mock_manager_class, runner): + """CLI passes CONTAINER_IMAGE value to ContainerManager.""" + custom = "ghcr.io/my-org/custom:v1" + mock_manager = MagicMock() + mock_manager.execute_vicar_command.return_value = 0 + mock_manager_class.return_value = mock_manager + + with patch.dict(os.environ, {"CONTAINER_IMAGE": custom}): + runner.invoke(main, ['marsmap', 'input.vic']) + + call_args = mock_manager_class.call_args + assert call_args[0][0] == custom + + +@patch('tig_cli.cli.ContainerManager') +def test_cli_uses_default_image_when_env_unset(mock_manager_class, runner): + """CLI uses default image when CONTAINER_IMAGE not set.""" + mock_manager = MagicMock() + mock_manager.execute_vicar_command.return_value = 0 + mock_manager_class.return_value = mock_manager + + env = {k: v for k, v in os.environ.items() if k != "CONTAINER_IMAGE"} + with patch.dict(os.environ, env, clear=True): + runner.invoke(main, ['marsmap', 'input.vic']) + + call_args = mock_manager_class.call_args + assert call_args[0][0] == DEFAULT_IMAGE + + +@patch('tig_cli.cli.ContainerManager') +def test_cli_with_writable_path_option(mock_manager_class, runner): + """--writable-path flag passed to start_container.""" + mock_manager = MagicMock() + mock_manager.execute_vicar_command.return_value = 0 + mock_manager_class.return_value = mock_manager + + result = runner.invoke(main, ['--writable-path', '/data', 'marsmap', 'input.vic']) + + assert result.exit_code == 0 + mock_manager.start_container.assert_called_once_with(writable_paths=['/data']) + + +@patch('tig_cli.cli.ContainerManager') +def test_cli_with_multiple_writable_paths(mock_manager_class, runner): + """Multiple --writable-path flags all passed to start_container.""" + mock_manager = MagicMock() + mock_manager.execute_vicar_command.return_value = 0 + mock_manager_class.return_value = mock_manager + + result = runner.invoke(main, [ + '--writable-path', '/data', + '--writable-path', '/output', + 'marsmap', 'input.vic' + ]) + + assert result.exit_code == 0 + mock_manager.start_container.assert_called_once_with( + writable_paths=['/data', '/output'] + ) + + +@patch('tig_cli.cli.ContainerManager') +def test_cli_with_disable_path_translation(mock_manager_class, runner): + """--disable-path-translation passed to ContainerManager.""" + mock_manager = MagicMock() + mock_manager.execute_vicar_command.return_value = 0 + mock_manager_class.return_value = mock_manager + + result = runner.invoke(main, ['--disable-path-translation', 'marsmap', 'input.vic']) + + assert result.exit_code == 0 + call_kwargs = mock_manager_class.call_args[1] + assert call_kwargs['disable_path_translation'] is True + + +@patch('tig_cli.cli.ContainerManager') +def test_cli_stops_container_on_error(mock_manager_class, runner): + """Container is stopped even when command raises an exception.""" + mock_manager = MagicMock() + mock_manager.execute_vicar_command.side_effect = Exception("Test error") + mock_manager_class.return_value = mock_manager + + runner.invoke(main, ['marsmap', 'input.vic']) + + mock_manager.stop_container.assert_called_once() +``` + +- [ ] **Step 2: Run tests — expect ImportError** + +```bash +cd tig-cli +pytest tests/test_cli.py -v 2>&1 | head -20 +``` + +Expected: `ImportError` — `tig_cli.cli` doesn't exist yet. + +- [ ] **Step 3: Write `tig-cli/src/tig_cli/cli.py`** + +```python +"""TIG CLI — execute VICAR commands via Docker.""" +import sys +import click + +from .container import ContainerManager, get_container_image + + +@click.command( + context_settings=dict( + ignore_unknown_options=True, + allow_extra_args=True, + allow_interspersed_args=False, + ), +) +@click.argument("vicar_tool") +@click.argument("args", nargs=-1, type=click.UNPROCESSED) +@click.option( + "--writable-path", + multiple=True, + help="Additional writable paths to mount (can be specified multiple times)", +) +@click.option( + "--disable-path-translation", + is_flag=True, + help="Disable automatic path translation (for debugging)", +) +@click.pass_context +def main( + ctx: click.Context, + vicar_tool: str, + args: tuple, + writable_path: tuple, + disable_path_translation: bool, +) -> None: + """Execute a VICAR command via Docker. + + \b + Active image: {image} + + Set CONTAINER_IMAGE env var to override. + """.format(image=get_container_image()) + + manager = ContainerManager( + get_container_image(), + disable_path_translation=disable_path_translation, + ) + + try: + manager.start_container(writable_paths=list(writable_path)) + exit_code = manager.execute_vicar_command(vicar_tool, list(args)) + sys.exit(exit_code) + finally: + manager.stop_container() +``` + +- [ ] **Step 4: Run tests — expect all pass** + +```bash +cd tig-cli +pytest tests/test_cli.py -v +``` + +Expected: all 9 tests PASS. + +- [ ] **Step 5: Run full suite** + +```bash +cd tig-cli +pytest -m "not integration" -v +``` + +Expected: all tests PASS (path_translator + container + cli). + +- [ ] **Step 6: Commit** + +```bash +git add tig-cli/src/tig_cli/cli.py tig-cli/tests/test_cli.py +git commit -m "feat: add unified tig CLI with CONTAINER_IMAGE env var" +``` + +--- + +## Task 5: Port integration tests + +**Files:** +- Create: `tig-cli/tests/integration/test_vicar_execution.py` + +- [ ] **Step 1: Write `tig-cli/tests/integration/test_vicar_execution.py`** + +```python +"""Integration tests for VICAR command execution. + +These tests require Docker and the VICAR images to be available. +Run separately from unit tests: pytest -m integration +""" +import os +import pytest +from click.testing import CliRunner +from tig_cli.cli import main +from tig_cli.container import DEFAULT_IMAGE + +pytestmark = pytest.mark.integration + + +@pytest.fixture +def runner(): + return CliRunner() + + +@pytest.mark.skipif( + os.environ.get("SKIP_INTEGRATION") == "1", + reason="Integration tests skipped", +) +def test_tig_cli_help(runner): + """tig --help exits 0 and mentions VICAR.""" + result = runner.invoke(main, ['--help']) + assert result.exit_code == 0 + assert "VICAR" in result.output + + +@pytest.mark.skipif( + os.environ.get("SKIP_INTEGRATION") == "1", + reason="Integration tests skipped", +) +def test_tig_cli_help_shows_default_image(runner): + """tig --help shows the default image URI.""" + env = {k: v for k, v in os.environ.items() if k != "CONTAINER_IMAGE"} + with __import__('unittest.mock', fromlist=['patch']).patch.dict( + os.environ, env, clear=True + ): + result = runner.invoke(main, ['--help']) + assert DEFAULT_IMAGE in result.output + + +# Additional integration tests would go here. +# These require actual VICAR images and test data. Example: +# +# def test_execute_label_command(runner, tmp_path): +# test_file = tmp_path / "test.vic" +# test_file.write_bytes(b"test data") +# result = runner.invoke(main, ['label', f'INP={test_file}']) +# assert result.exit_code == 0 +``` + +- [ ] **Step 2: Run integration tests (will skip without SKIP_INTEGRATION)** + +```bash +cd tig-cli +pytest -m integration -v +``` + +Expected: 2 tests collected, either PASS (if Docker+image available) or SKIP. + +- [ ] **Step 3: Commit** + +```bash +git add tig-cli/tests/integration/test_vicar_execution.py +git commit -m "test: add integration test stubs for tig-cli" +``` + +--- + +## Task 6: Update CI workflows + +**Files:** +- Modify: `.github/workflows/test.yml` +- Modify: `.github/workflows/publish.yml` + +- [ ] **Step 1: Update `.github/workflows/test.yml`** + +Replace the entire file with: + +```yaml +name: Test + +on: [push, pull_request] + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + python-version: ["3.9", "3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + cd tig-cli + pip install -e ".[dev]" + + - name: Run tests + run: | + cd tig-cli + pytest -m "not integration" -v --cov=tig_cli --cov-report=xml + + - name: Upload coverage + uses: codecov/codecov-action@v3 + with: + files: ./tig-cli/coverage.xml +``` + +- [ ] **Step 2: Update `.github/workflows/publish.yml`** + +Replace the entire file with: + +```yaml +name: Publish + +on: + release: + types: [published] + +jobs: + publish: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.10" + + - name: Install build tools + run: pip install build twine + + - name: Build package + run: | + cd tig-cli && python -m build + + - name: Publish to PyPI + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} + run: | + twine upload tig-cli/dist/* +``` + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/test.yml .github/workflows/publish.yml +git commit -m "ci: update workflows for unified tig-cli package" +``` + +--- + +## Task 7: Delete old packages + +**Files:** +- Delete: `tig-cli-core/` (entire directory) +- Delete: `tig-opensource/` (entire directory) +- Delete: `tig-m20-g87/` (entire directory) + +- [ ] **Step 1: Remove old package directories** + +```bash +rm -rf tig-cli-core tig-opensource tig-m20-g87 +``` + +- [ ] **Step 2: Verify only expected directories remain** + +```bash +ls -la +``` + +Expected: `tig-cli-core/`, `tig-opensource/`, `tig-m20-g87/` are gone. `tig-cli/` remains. + +- [ ] **Step 3: Commit** + +```bash +git add -A +git commit -m "feat: remove old variant packages (replaced by tig-cli)" +``` + +--- + +## Task 8: Final verification + +- [ ] **Step 1: Run full unit test suite from tig-cli directory** + +```bash +cd tig-cli +pytest -m "not integration" -v --cov=tig_cli --cov-report=term-missing +``` + +Expected: all unit tests PASS, coverage report shows 100% on `container.py`, `path_translator.py`, `cli.py`. + +- [ ] **Step 2: Verify `tig --help` works** + +```bash +tig --help +``` + +Expected output contains: +- "Execute a VICAR command via Docker" +- The default image URI: `ghcr.io/nasa-ammos/tig/terrain-intelligence-generator:opensource` +- `--writable-path` option +- `--disable-path-translation` option + +- [ ] **Step 3: Verify CONTAINER_IMAGE override works** + +```bash +CONTAINER_IMAGE=my-custom/image:v1 tig --help +``` + +Expected: `my-custom/image:v1` appears in the help output. + +- [ ] **Step 4: Commit if any cleanup needed, then final commit** + +```bash +git log --oneline -10 +``` + +Confirm all tasks have their own commits in clean order. diff --git a/tig-cli/MANIFEST.in b/tig-cli/MANIFEST.in new file mode 100644 index 0000000..db898eb --- /dev/null +++ b/tig-cli/MANIFEST.in @@ -0,0 +1,3 @@ +include README.md +include LICENSE +recursive-include src/tig_cli *.py diff --git a/tig-cli/README.md b/tig-cli/README.md new file mode 100644 index 0000000..c7c61a0 --- /dev/null +++ b/tig-cli/README.md @@ -0,0 +1 @@ +# tig-cli diff --git a/tig-cli/pyproject.toml b/tig-cli/pyproject.toml new file mode 100644 index 0000000..5c44ca2 --- /dev/null +++ b/tig-cli/pyproject.toml @@ -0,0 +1,41 @@ +[project] +name = "tig-cli" +version = "0.1.0" +description = "TIG CLI for running VICAR commands via Docker" +requires-python = ">=3.9" +dependencies = [ + "click>=8.0.0", + "docker>=6.0.0", +] +authors = [ + {name = "NASA AMMOS", email = "ammos@jpl.nasa.gov"} +] +readme = "README.md" +license = {text = "Apache-2.0"} + +[project.scripts] +tig = "tig_cli.cli:main" + +[project.optional-dependencies] +dev = [ + "pytest>=7.0.0", + "pytest-cov>=4.0.0", + "pytest-mock>=3.10.0", +] + +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = "-v --cov=tig_cli --cov-report=term-missing" +markers = [ + "integration: marks tests as integration tests (deselect with '-m \"not integration\"')", +] diff --git a/tig-cli/src/tig_cli/__init__.py b/tig-cli/src/tig_cli/__init__.py new file mode 100644 index 0000000..d29a730 --- /dev/null +++ b/tig-cli/src/tig_cli/__init__.py @@ -0,0 +1,6 @@ +"""TIG CLI — run VICAR commands via Docker. + +Configure the backend image with the CONTAINER_IMAGE environment variable. +""" + +__version__ = "0.1.0" diff --git a/tig-cli/src/tig_cli/__main__.py b/tig-cli/src/tig_cli/__main__.py new file mode 100644 index 0000000..de1e7c2 --- /dev/null +++ b/tig-cli/src/tig_cli/__main__.py @@ -0,0 +1,5 @@ +"""Allow running tig_cli as a module: python -m tig_cli""" +from .cli import main + +if __name__ == "__main__": + main() diff --git a/tig-cli/src/tig_cli/cli.py b/tig-cli/src/tig_cli/cli.py new file mode 100644 index 0000000..d686e14 --- /dev/null +++ b/tig-cli/src/tig_cli/cli.py @@ -0,0 +1,50 @@ +"""CLI entry point for the tig command.""" +import sys + +import click + +from .container import ContainerManager, get_container_image + + +class DynamicHelpCommand(click.Command): + def format_help(self, ctx, formatter): + image = get_container_image() + self.help = ( + f"Execute a VICAR tool via Docker.\n\n" + f"Active image: {image}\n\n" + f"Set CONTAINER_IMAGE env var to override." + ) + super().format_help(ctx, formatter) + + +@click.command( + cls=DynamicHelpCommand, + context_settings=dict( + ignore_unknown_options=True, + allow_extra_args=True, + allow_interspersed_args=False, + ), +) +@click.argument("vicar_tool") +@click.argument("args", nargs=-1, type=click.UNPROCESSED) +@click.option( + "--writable-path", + multiple=True, + metavar="PATH", + help="Additional host path to mount as read-write in the container.", +) +@click.option( + "--disable-path-translation", + is_flag=True, + default=False, + help="Disable automatic host→container path translation (for debugging).", +) +def main(vicar_tool, args, writable_path, disable_path_translation): + image = get_container_image() + manager = ContainerManager(image, disable_path_translation=disable_path_translation) + try: + manager.start_container(writable_paths=list(writable_path)) + exit_code = manager.execute_vicar_command(vicar_tool, list(args)) + sys.exit(exit_code) + finally: + manager.stop_container() diff --git a/tig-cli/src/tig_cli/container.py b/tig-cli/src/tig_cli/container.py new file mode 100644 index 0000000..858427d --- /dev/null +++ b/tig-cli/src/tig_cli/container.py @@ -0,0 +1,151 @@ +"""Container lifecycle management.""" +import os +import subprocess +import sys +from pathlib import Path +from typing import List, Dict, Any, Optional +import docker + +from .path_translator import PathTranslator + +DEFAULT_IMAGE = "ghcr.io/nasa-ammos/tig/terrain-intelligence-generator:opensource" + + +def get_container_image() -> str: + """Return the Docker image to use for VICAR execution. + + Reads CONTAINER_IMAGE environment variable. Falls back to the + opensource image if not set. + """ + return os.environ.get("CONTAINER_IMAGE", DEFAULT_IMAGE) + + +class ContainerManager: + """Manages VICAR container lifecycle and execution. + + Handles starting containers with appropriate mounts, + executing VICAR commands, and cleanup. + """ + + def __init__( + self, + image: str, + disable_path_translation: bool = False + ): + """Initialize the container manager. + + Args: + image: Docker image name and tag + disable_path_translation: Skip path translation (for debugging) + """ + self.image = image + self.disable_path_translation = disable_path_translation + try: + self.client = docker.from_env() + except docker.errors.DockerException as e: + raise RuntimeError( + "Failed to connect to Docker. Is the Docker daemon running?" + ) from e + self.container_name = f"tig-vicar-{os.getpid()}" + home = os.environ.get("HOME") or str(Path.home()) + self.translator = PathTranslator(home) + self.container: Optional[Any] = None + + def _build_volume_mounts( + self, + writable_paths: List[str] + ) -> Dict[str, Dict[str, str]]: + """Build volume mount configuration. + + Args: + writable_paths: Additional paths to mount as read-write + + Returns: + Dictionary of volume mounts for docker-py + """ + home = os.environ.get("HOME") or str(Path.home()) + + volumes = { + "/": {"bind": "/host", "mode": "ro"}, + home: {"bind": home, "mode": "rw"}, + } + + for path in writable_paths: + if os.path.isdir(path): + volumes[path] = {"bind": f"/host{path}", "mode": "rw"} + + return volumes + + def start_container(self, writable_paths: List[str]) -> None: + """Start the VICAR container with appropriate mounts. + + Args: + writable_paths: Additional paths to mount as read-write + """ + volumes = self._build_volume_mounts(writable_paths) + + environment = {} + extra_kwargs = {} + + if sys.platform == "darwin": + environment["DISPLAY"] = "host.docker.internal:0" + else: + environment["DISPLAY"] = os.environ.get("DISPLAY", ":0") + volumes["/tmp/.X11-unix"] = {"bind": "/tmp/.X11-unix", "mode": "rw"} + extra_kwargs["network_mode"] = "host" + + self.container = self.client.containers.run( + image=self.image, + name=self.container_name, + volumes=volumes, + environment=environment, + detach=True, + command="tail -f /dev/null", + **extra_kwargs + ) + + def stop_container(self) -> None: + """Stop and remove the container.""" + if self.container: + try: + self.container.stop() + except docker.errors.APIError: + pass + try: + self.container.remove() + except docker.errors.APIError: + pass + + def execute_vicar_command( + self, + vicar_tool: str, + args: List[str] + ) -> int: + """Execute a VICAR command in the container. + + Args: + vicar_tool: VICAR tool name (e.g., "marsmap", "label") + args: Command arguments + + Returns: + Exit code from command execution + """ + if self.disable_path_translation: + translated_args = args + container_cwd = os.getcwd() + else: + translated_args = self.translator.translate_args(args) + container_cwd = self.translator.get_container_cwd(os.getcwd()) + + exec_args = [ + "docker", "exec", + "-w", container_cwd, + "-e", "XFILESEARCHPATH=/usr/local/vicar/gui/%N", + "-e", "XBMLANGPATH=/usr/local/vicar/gui/%L", + self.container_name, + vicar_tool, + *translated_args + ] + + result = subprocess.run(exec_args) + return result.returncode diff --git a/tig-cli/src/tig_cli/path_translator.py b/tig-cli/src/tig_cli/path_translator.py new file mode 100644 index 0000000..09931ef --- /dev/null +++ b/tig-cli/src/tig_cli/path_translator.py @@ -0,0 +1,75 @@ +"""Path translation for host-to-container path mapping.""" +import os +from pathlib import Path +from typing import List + + +class PathTranslator: + """Translates host paths to container paths. + + Handles the mapping between host filesystem paths and their + corresponding paths inside the container: + - Relative paths: unchanged + - Home directory paths: unchanged (mounted directly) + - Other absolute paths: prefixed with /host + """ + + def __init__(self, home: str): + """Initialize the path translator. + + Args: + home: Home directory path (typically os.environ["HOME"]) + """ + self.home = Path(home).resolve() + + def translate(self, path: str) -> str: + """Translate a single path from host to container. + + Args: + path: Host filesystem path + + Returns: + Container filesystem path + """ + if not path: + return path + + # Relative path - no translation + if not os.path.isabs(path): + return path + + resolved = Path(path).resolve() + + # Home directory - mounted directly at same path + if resolved.is_relative_to(self.home): + return str(resolved) + + # Absolute path outside home - add /host prefix + return f"/host{resolved}" + + def translate_args(self, args: List[str]) -> List[str]: + """Translate a list of arguments. + + Args: + args: List of command arguments (may contain paths) + + Returns: + List of arguments with paths translated + """ + return [self.translate(arg) for arg in args] + + def get_container_cwd(self, host_cwd: str) -> str: + """Map host CWD to container CWD. + + Args: + host_cwd: Host current working directory + + Returns: + Container working directory path + """ + cwd = Path(host_cwd).resolve() + + if cwd.is_relative_to(self.home): + return str(cwd) + + return f"/host{cwd}" diff --git a/tig-cli/tests/__init__.py b/tig-cli/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tig-cli/tests/integration/__init__.py b/tig-cli/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tig-cli/tests/integration/test_vicar_execution.py b/tig-cli/tests/integration/test_vicar_execution.py new file mode 100644 index 0000000..c7e82c5 --- /dev/null +++ b/tig-cli/tests/integration/test_vicar_execution.py @@ -0,0 +1,75 @@ +"""Integration tests for VICAR execution via Docker. + +These tests require Docker to be running and the TIG image to be available. +Run with: pytest -m integration + +Mark: pytest.ini_options in pyproject.toml defines the 'integration' marker. +""" +import os +import subprocess +import pytest + +from tig_cli.container import ContainerManager, get_container_image + + +@pytest.mark.integration +def test_vicar_help_command(): + """Can execute a VICAR command and capture exit code.""" + manager = ContainerManager(get_container_image()) + try: + manager.start_container([]) + # viccub with missing param exits 1 but proves container execution works + exit_code = manager.execute_vicar_command("viccub", []) + finally: + manager.stop_container() + + # TAE error for missing param returns 1, but that proves execution worked + assert exit_code == 1 + + +@pytest.mark.integration +def test_vicar_command_with_path_translation(tmp_path): + """Path translation works for real file args.""" + # Create a small test file on host + test_file = tmp_path / "test.txt" + test_file.write_text("test content") + + manager = ContainerManager(get_container_image()) + try: + manager.start_container([]) + # Use a simple VICAR command that reads the file + # label just reads metadata so won't fail on non-VICAR files + exit_code = manager.execute_vicar_command("label", [str(test_file)]) + finally: + manager.stop_container() + + # label will return non-zero on a non-VICAR file but should NOT crash the manager + # The important thing is that path translation ran without exception + assert exit_code is not None + + +@pytest.mark.integration +def test_container_image_from_env(): + """CONTAINER_IMAGE env var controls which image is used.""" + image = get_container_image() + assert image # Not empty + + # Verify the image is pullable (or cached) by trying to start it + manager = ContainerManager(image) + try: + manager.start_container([]) + finally: + manager.stop_container() + + +@pytest.mark.integration +def test_cli_help_invocation(): + """tig --help exits 0 and shows expected content.""" + result = subprocess.run( + ["python3", "-m", "tig_cli", "--help"], + capture_output=True, + text=True + ) + # --help should exit 0 + assert result.returncode == 0 + assert "CONTAINER_IMAGE" in result.stdout diff --git a/tig-cli/tests/test_cli.py b/tig-cli/tests/test_cli.py new file mode 100644 index 0000000..e62df1e --- /dev/null +++ b/tig-cli/tests/test_cli.py @@ -0,0 +1,151 @@ +"""Tests for CLI entry point.""" +import os +import sys +import pytest +from unittest.mock import patch, MagicMock +from click.testing import CliRunner +from tig_cli.cli import main +from tig_cli.container import DEFAULT_IMAGE + + +def test_help_text_shows_image(): + """Help text shows the active container image.""" + runner = CliRunner() + result = runner.invoke(main, ["--help"]) + assert result.exit_code == 0 + assert DEFAULT_IMAGE in result.output + + +def test_help_text_shows_env_var_hint(): + """Help text mentions CONTAINER_IMAGE.""" + runner = CliRunner() + result = runner.invoke(main, ["--help"]) + assert "CONTAINER_IMAGE" in result.output + + +def test_custom_image_in_help(): + """When CONTAINER_IMAGE is set, help text shows that image.""" + custom = "ghcr.io/my-org/custom-vicar:v2" + runner = CliRunner() + with patch.dict(os.environ, {"CONTAINER_IMAGE": custom}): + result = runner.invoke(main, ["--help"]) + assert custom in result.output + + +def test_basic_command_execution(): + """CLI invokes ContainerManager with correct arguments.""" + runner = CliRunner() + mock_manager = MagicMock() + mock_manager.execute_vicar_command.return_value = 0 + + with patch('tig_cli.cli.ContainerManager', return_value=mock_manager), \ + patch('tig_cli.cli.get_container_image', return_value=DEFAULT_IMAGE): + result = runner.invoke(main, ["marsmap", "input.vic", "output.vic"]) + + mock_manager.start_container.assert_called_once_with(writable_paths=[]) + mock_manager.execute_vicar_command.assert_called_once_with( + "marsmap", ["input.vic", "output.vic"] + ) + mock_manager.stop_container.assert_called_once() + + +def test_writable_path_option(): + """--writable-path passed through to ContainerManager.""" + runner = CliRunner() + mock_manager = MagicMock() + mock_manager.execute_vicar_command.return_value = 0 + + with patch('tig_cli.cli.ContainerManager', return_value=mock_manager), \ + patch('tig_cli.cli.get_container_image', return_value=DEFAULT_IMAGE): + result = runner.invoke(main, [ + "--writable-path", "/data", + "--writable-path", "/scratch", + "marsmap" + ]) + + mock_manager.start_container.assert_called_once_with( + writable_paths=["/data", "/scratch"] + ) + + +def test_disable_path_translation_option(): + """--disable-path-translation passed to ContainerManager.""" + runner = CliRunner() + mock_manager = MagicMock() + mock_manager.execute_vicar_command.return_value = 0 + + with patch('tig_cli.cli.ContainerManager') as mock_cls, \ + patch('tig_cli.cli.get_container_image', return_value=DEFAULT_IMAGE): + mock_cls.return_value = mock_manager + result = runner.invoke(main, ["--disable-path-translation", "marsmap"]) + + mock_cls.assert_called_once_with(DEFAULT_IMAGE, disable_path_translation=True) + + +def test_stop_container_called_on_success(): + """stop_container called even when command succeeds.""" + runner = CliRunner() + mock_manager = MagicMock() + mock_manager.execute_vicar_command.return_value = 0 + + with patch('tig_cli.cli.ContainerManager', return_value=mock_manager), \ + patch('tig_cli.cli.get_container_image', return_value=DEFAULT_IMAGE): + runner.invoke(main, ["marsmap"]) + + mock_manager.stop_container.assert_called_once() + + +def test_stop_container_called_on_error(): + """stop_container called even when command raises an exception.""" + runner = CliRunner() + mock_manager = MagicMock() + mock_manager.execute_vicar_command.side_effect = RuntimeError("boom") + + with patch('tig_cli.cli.ContainerManager', return_value=mock_manager), \ + patch('tig_cli.cli.get_container_image', return_value=DEFAULT_IMAGE): + result = runner.invoke(main, ["marsmap"]) + + mock_manager.stop_container.assert_called_once() + + +def test_exit_code_propagated(): + """CLI exits with the vicar tool's return code.""" + runner = CliRunner() + mock_manager = MagicMock() + mock_manager.execute_vicar_command.return_value = 42 + + with patch('tig_cli.cli.ContainerManager', return_value=mock_manager), \ + patch('tig_cli.cli.get_container_image', return_value=DEFAULT_IMAGE): + result = runner.invoke(main, ["marsmap"]) + + assert result.exit_code == 42 + + +def test_uses_container_image_env_var(): + """Uses CONTAINER_IMAGE env var when set.""" + custom = "ghcr.io/my-org/custom:latest" + runner = CliRunner() + mock_manager = MagicMock() + mock_manager.execute_vicar_command.return_value = 0 + + with patch('tig_cli.cli.ContainerManager') as mock_cls, \ + patch.dict(os.environ, {"CONTAINER_IMAGE": custom}): + mock_cls.return_value = mock_manager + result = runner.invoke(main, ["marsmap"]) + + mock_cls.assert_called_once_with(custom, disable_path_translation=False) + + +def test_passes_unknown_args_to_vicar_tool(): + """Unknown args (VICAR keyword=value format) passed through.""" + runner = CliRunner() + mock_manager = MagicMock() + mock_manager.execute_vicar_command.return_value = 0 + + with patch('tig_cli.cli.ContainerManager', return_value=mock_manager), \ + patch('tig_cli.cli.get_container_image', return_value=DEFAULT_IMAGE): + result = runner.invoke(main, ["marsmap", "INP=input.vic", "SIZE=(1,1,500,500)"]) + + mock_manager.execute_vicar_command.assert_called_once_with( + "marsmap", ["INP=input.vic", "SIZE=(1,1,500,500)"] + ) diff --git a/tig-cli/tests/test_container.py b/tig-cli/tests/test_container.py new file mode 100644 index 0000000..6664465 --- /dev/null +++ b/tig-cli/tests/test_container.py @@ -0,0 +1,198 @@ +"""Tests for container management.""" +import os +import pytest +from unittest.mock import Mock, patch, MagicMock +from tig_cli.container import ContainerManager, get_container_image, DEFAULT_IMAGE + + +# --- get_container_image --- + +def test_get_container_image_default(): + """Returns default image when CONTAINER_IMAGE not set.""" + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("CONTAINER_IMAGE", None) + assert get_container_image() == DEFAULT_IMAGE + + +def test_get_container_image_from_env(): + """Returns value of CONTAINER_IMAGE env var.""" + custom = "ghcr.io/my-org/custom-vicar:v2" + with patch.dict(os.environ, {"CONTAINER_IMAGE": custom}): + assert get_container_image() == custom + + +# --- ContainerManager init --- + +@pytest.fixture +def home_dir(tmp_path): + return str(tmp_path / "home" / "user") + + +def test_container_manager_init(home_dir): + """ContainerManager initializes with image string.""" + with patch('tig_cli.container.docker.from_env'), \ + patch.dict(os.environ, {"HOME": home_dir}): + manager = ContainerManager("test-image:latest") + assert manager.image == "test-image:latest" + assert manager.container_name.startswith("tig-vicar-") + + +def test_container_manager_default_no_translation(home_dir): + """Path translation enabled by default.""" + with patch('tig_cli.container.docker.from_env'), \ + patch.dict(os.environ, {"HOME": home_dir}): + manager = ContainerManager("test-image:latest") + assert manager.disable_path_translation is False + + +# --- _build_volume_mounts --- + +def test_build_volume_mounts_basic(home_dir): + with patch('tig_cli.container.docker.from_env'), \ + patch.dict(os.environ, {"HOME": home_dir}): + manager = ContainerManager("test-image:latest") + volumes = manager._build_volume_mounts([]) + + assert "/" in volumes + assert volumes["/"]["bind"] == "/host" + assert volumes["/"]["mode"] == "ro" + assert home_dir in volumes + assert volumes[home_dir]["bind"] == home_dir + assert volumes[home_dir]["mode"] == "rw" + + +def test_build_volume_mounts_with_writable_paths(home_dir, tmp_path): + writable_path = str(tmp_path / "data") + os.makedirs(writable_path, exist_ok=True) + + with patch('tig_cli.container.docker.from_env'), \ + patch.dict(os.environ, {"HOME": home_dir}): + manager = ContainerManager("test-image:latest") + volumes = manager._build_volume_mounts([writable_path]) + + assert writable_path in volumes + assert volumes[writable_path]["bind"] == f"/host{writable_path}" + assert volumes[writable_path]["mode"] == "rw" + + +def test_build_volume_mounts_skips_nonexistent_paths(home_dir): + with patch('tig_cli.container.docker.from_env'), \ + patch.dict(os.environ, {"HOME": home_dir}): + manager = ContainerManager("test-image:latest") + volumes = manager._build_volume_mounts(["/nonexistent/path"]) + + assert "/nonexistent/path" not in volumes + + +# --- start_container --- + +@patch('tig_cli.container.docker.from_env') +def test_start_container_linux(mock_docker, home_dir): + mock_client = MagicMock() + mock_docker.return_value = mock_client + + with patch.dict(os.environ, {"HOME": home_dir, "DISPLAY": ":0"}): + manager = ContainerManager("test-image:latest") + with patch('sys.platform', 'linux'): + manager.start_container([]) + + call_kwargs = mock_client.containers.run.call_args[1] + assert call_kwargs['image'] == "test-image:latest" + assert call_kwargs['detach'] is True + assert call_kwargs['network_mode'] == 'host' + assert 'DISPLAY' in call_kwargs['environment'] + + +@patch('tig_cli.container.docker.from_env') +def test_start_container_macos(mock_docker, home_dir): + mock_client = MagicMock() + mock_docker.return_value = mock_client + + with patch.dict(os.environ, {"HOME": home_dir}): + manager = ContainerManager("test-image:latest") + with patch('sys.platform', 'darwin'): + manager.start_container([]) + + call_kwargs = mock_client.containers.run.call_args[1] + assert call_kwargs['environment']['DISPLAY'] == 'host.docker.internal:0' + assert 'network_mode' not in call_kwargs + + +# --- stop_container --- + +@patch('tig_cli.container.docker.from_env') +def test_stop_container(mock_docker, home_dir): + mock_client = MagicMock() + mock_container = MagicMock() + mock_docker.return_value = mock_client + + with patch.dict(os.environ, {"HOME": home_dir}): + manager = ContainerManager("test-image:latest") + manager.container = mock_container + manager.stop_container() + + mock_container.stop.assert_called_once() + mock_container.remove.assert_called_once() + + +@patch('tig_cli.container.docker.from_env') +def test_stop_container_no_container(mock_docker, home_dir): + """stop_container is safe to call when container never started.""" + mock_docker.return_value = MagicMock() + with patch.dict(os.environ, {"HOME": home_dir}): + manager = ContainerManager("test-image:latest") + manager.stop_container() # should not raise + + +# --- execute_vicar_command --- + +@patch('tig_cli.container.subprocess.run') +def test_execute_vicar_command(mock_run, home_dir): + mock_run.return_value = Mock(returncode=0) + + with patch('tig_cli.container.docker.from_env'), \ + patch.dict(os.environ, {"HOME": home_dir}): + manager = ContainerManager("test-image:latest") + with patch('os.getcwd', return_value=f"{home_dir}/projects"): + exit_code = manager.execute_vicar_command("marsmap", ["input.vic", "output.vic"]) + + assert exit_code == 0 + call_args = mock_run.call_args[0][0] + assert call_args[0] == "docker" + assert call_args[1] == "exec" + assert "marsmap" in call_args + assert "input.vic" in call_args + assert "output.vic" in call_args + + +@patch('tig_cli.container.subprocess.run') +def test_execute_vicar_command_with_path_translation(mock_run, home_dir): + mock_run.return_value = Mock(returncode=0) + + with patch('tig_cli.container.docker.from_env'), \ + patch.dict(os.environ, {"HOME": home_dir}): + manager = ContainerManager("test-image:latest") + with patch('os.getcwd', return_value=f"{home_dir}/projects"): + manager.execute_vicar_command( + "marsmap", + ["/data/input.vic", f"{home_dir}/output.vic"] + ) + + call_args = mock_run.call_args[0][0] + assert "/host/data/input.vic" in call_args + assert f"{home_dir}/output.vic" in call_args + + +@patch('tig_cli.container.subprocess.run') +def test_execute_vicar_command_without_translation(mock_run, home_dir): + mock_run.return_value = Mock(returncode=0) + + with patch('tig_cli.container.docker.from_env'), \ + patch.dict(os.environ, {"HOME": home_dir}): + manager = ContainerManager("test-image:latest", disable_path_translation=True) + with patch('os.getcwd', return_value=f"{home_dir}/projects"): + manager.execute_vicar_command("marsmap", ["/data/input.vic"]) + + call_args = mock_run.call_args[0][0] + assert "/data/input.vic" in call_args + assert "/host/data/input.vic" not in call_args diff --git a/tig-cli/tests/test_path_translator.py b/tig-cli/tests/test_path_translator.py new file mode 100644 index 0000000..87dad80 --- /dev/null +++ b/tig-cli/tests/test_path_translator.py @@ -0,0 +1,107 @@ +"""Tests for path translation.""" +import pytest +from tig_cli.path_translator import PathTranslator + + +@pytest.fixture +def home_dir(tmp_path): + """Create a temporary home directory.""" + return str(tmp_path / "home" / "user") + + +@pytest.fixture +def translator(home_dir): + """Create a PathTranslator instance.""" + return PathTranslator(home_dir) + + +def test_relative_path_unchanged(translator): + assert translator.translate("file.vic") == "file.vic" + assert translator.translate("./data/file.vic") == "./data/file.vic" + assert translator.translate("../other/file.vic") == "../other/file.vic" + + +def test_home_path_unchanged(translator, home_dir): + path = f"{home_dir}/data/file.vic" + assert translator.translate(path) == path + + +def test_system_path_gets_host_prefix(translator): + assert translator.translate("/data/file.vic") == "/host/data/file.vic" + assert translator.translate("/tmp/output.vic") == "/host/tmp/output.vic" + + +def test_empty_path_unchanged(translator): + assert translator.translate("") == "" + + +def test_translate_args_list(translator, home_dir): + args = [ + "file.vic", + f"{home_dir}/input.vic", + "/data/system.vic", + ] + expected = [ + "file.vic", + f"{home_dir}/input.vic", + "/host/data/system.vic", + ] + assert translator.translate_args(args) == expected + + +def test_get_container_cwd_in_home(translator, home_dir): + cwd = f"{home_dir}/projects/vicar" + assert translator.get_container_cwd(cwd) == cwd + + +def test_get_container_cwd_outside_home(translator): + cwd = "/opt/vicar/workspace" + assert translator.get_container_cwd(cwd) == "/host/opt/vicar/workspace" + + +def test_home_directory_itself(translator, home_dir): + assert translator.translate(home_dir) == home_dir + + +def test_root_path_gets_host_prefix(translator): + assert translator.translate("/") == "/host/" + + +def test_path_with_spaces(translator, home_dir): + path = f"{home_dir}/my documents/file.vic" + assert translator.translate(path) == path + system_path = "/data/my files/image.vic" + assert translator.translate(system_path) == "/host/data/my files/image.vic" + + +def test_path_with_special_characters(translator): + assert translator.translate("/data/file-name.vic") == "/host/data/file-name.vic" + assert translator.translate("/data/file_name.vic") == "/host/data/file_name.vic" + assert translator.translate("/data/file.name.vic") == "/host/data/file.name.vic" + + +def test_non_path_arguments(translator): + assert translator.translate("123") == "123" + assert translator.translate("3.14") == "3.14" + assert translator.translate("-v") == "-v" + assert translator.translate("--verbose") == "--verbose" + assert translator.translate("INP=file.vic") == "INP=file.vic" + assert translator.translate("OUT=/tmp/out.vic") == "OUT=/tmp/out.vic" + + +def test_translate_args_mixed_types(translator, home_dir): + args = [ + "marsmap", + "-v", + f"{home_dir}/input.vic", + "/data/system.vic", + "output.vic", + "SIZE=(1,1,1024,1024)", + ] + result = translator.translate_args(args) + assert result[0] == "marsmap" + assert result[1] == "-v" + assert result[2] == f"{home_dir}/input.vic" + assert result[3] == "/host/data/system.vic" + assert result[4] == "output.vic" + assert result[5] == "SIZE=(1,1,1024,1024)" From 90e1b48226fc9cd0658757407f07447fb986cad0 Mon Sep 17 00:00:00 2001 From: Jason Han Date: Tue, 4 Aug 2026 11:01:36 -0700 Subject: [PATCH 2/3] chore(tig-cli): add LICENSE and README, remove non-functional GeoCal files - Add Apache-2.0 LICENSE to tig-cli/ (resolves MANIFEST.in include and pyproject license) - Populate tig-cli/README.md with install/usage/config docs - Remove unrelated, non-functional GeoCal artifacts (Dockerfile.geocal, build-geocal-image.sh, docs/geocal-integration-status.md) --- Dockerfile.geocal | 171 ------------------------- build-geocal-image.sh | 92 -------------- docs/geocal-integration-status.md | 113 ----------------- tig-cli/LICENSE | 202 ++++++++++++++++++++++++++++++ tig-cli/README.md | 86 +++++++++++++ 5 files changed, 288 insertions(+), 376 deletions(-) delete mode 100644 Dockerfile.geocal delete mode 100755 build-geocal-image.sh delete mode 100644 docs/geocal-integration-status.md create mode 100644 tig-cli/LICENSE diff --git a/Dockerfile.geocal b/Dockerfile.geocal deleted file mode 100644 index 62010e6..0000000 --- a/Dockerfile.geocal +++ /dev/null @@ -1,171 +0,0 @@ -# Multi-stage Dockerfile to build GeoCal from source with patched GDAL detection -# Patches ac_gdal.m4 to use gdal-config instead of AC_RUN_IFELSE - -ARG BASE_IMAGE=ghcr.io/nasa-ammos/tig/terrain-intelligence-generator:opensource - -# ============================================================================ -# Builder stage -# ============================================================================ -FROM ${BASE_IMAGE} as builder - -# Install bzip2 and build deps -RUN dnf install -y bzip2 wget git ncompress autoconf automake libtool m4 patch && dnf clean all - -# Install micromamba -RUN curl -Ls https://micro.mamba.pm/api/micromamba/linux-64/latest | tar -xvj -C / bin/micromamba && \ - mkdir -p /opt/conda - -ENV MAMBA_ROOT_PREFIX=/opt/conda -ENV PATH="/opt/conda/bin:${PATH}" - -# Install build tools and deps via conda-forge -RUN /bin/micromamba install -y -c conda-forge \ - gxx_linux-64 \ - gcc_linux-64 \ - gfortran_linux-64 \ - make \ - cmake \ - swig \ - gdal \ - libgdal \ - boost-cpp \ - gsl \ - fftw \ - python=3.9 \ - numpy \ - scipy \ - -p /opt/conda && \ - /bin/micromamba clean -afy - -# Set conda compilers -ENV CC="/opt/conda/bin/x86_64-conda-linux-gnu-gcc" -ENV CXX="/opt/conda/bin/x86_64-conda-linux-gnu-g++" -ENV FC="/opt/conda/bin/x86_64-conda-linux-gnu-gfortran" -ENV LD_LIBRARY_PATH="/opt/conda/lib:${LD_LIBRARY_PATH}" - -WORKDIR /build - -# Build CSPICE -RUN echo "=== Building CSPICE ===" && \ - curl -k -L https://naif.jpl.nasa.gov/pub/naif/toolkit/C/PC_Linux_GCC_64bit/packages/cspice.tar.Z | uncompress | tar -x && \ - cd cspice/src/cspice && \ - sed -i 's/-c /-fPIC -c /g' mkprodct.csh && \ - ./mkprodct.csh && \ - cd ../.. && \ - mkdir -p /usr/local/cspice && \ - cp -r include lib /usr/local/cspice/ - -# Build Blitz++ -RUN echo "=== Building Blitz++ ===" && \ - wget https://github.com/blitzpp/blitz/archive/refs/tags/1.0.2.tar.gz -O blitz-1.0.2.tar.gz && \ - tar xzf blitz-1.0.2.tar.gz && \ - cd blitz-1.0.2 && \ - mkdir build && cd build && \ - cmake .. \ - -DCMAKE_INSTALL_PREFIX=/usr/local \ - -DCMAKE_CXX_STANDARD=14 \ - -DBUILD_TESTING=OFF \ - -DCMAKE_POLICY_VERSION_MINIMUM=3.5 && \ - make -j$(nproc) && \ - make install - -# Clone GeoCal and patch GDAL detection -ARG GEOCAL_VERSION=e4c3cb071f3063352bd35b3048ddad1c077e10db -RUN echo "=== Cloning GeoCal ===" && \ - git clone https://github.com/Cartography-jpl/geocal.git && \ - cd geocal && \ - git checkout ${GEOCAL_VERSION} - -# Apply patch to fix GDAL version detection (use gdal-config instead of AC_RUN_IFELSE) -RUN cd /build/geocal && \ - sed -i '81,92d' config/m4/ac_gdal.m4 && \ - sed -i '81i\ AC_MSG_CHECKING([GDAL version via gdal-config])' config/m4/ac_gdal.m4 && \ - sed -i '82i\ # Use gdal-config --version instead of runtime test' config/m4/ac_gdal.m4 && \ - sed -i '83i\ # This avoids AC_RUN_IFELSE which fails in Docker builds' config/m4/ac_gdal.m4 && \ - sed -i '84i\ if test -x "$GDAL_PREFIX/bin/gdal-config"; then' config/m4/ac_gdal.m4 && \ - sed -i '85i\ gdal_version=`$GDAL_PREFIX/bin/gdal-config --version`' config/m4/ac_gdal.m4 && \ - sed -i '86i\ fi' config/m4/ac_gdal.m4 && \ - sed -i '87i\ AC_MSG_RESULT([${gdal_version}])' config/m4/ac_gdal.m4 - -# Regenerate configure script with patched m4 -RUN cd /build/geocal && \ - ./bootstrap - -# Configure and build GeoCal -RUN cd /build/geocal && \ - mkdir build && cd build && \ - ../configure \ - --prefix=/usr/local/geocal \ - --with-spice=/usr/local/cspice \ - --with-vicar-rtl=/usr/local/vicar/dev \ - --with-blitz=/usr/local \ - --with-boost=/opt/conda \ - --with-gdal=/opt/conda \ - --without-mspi-shared \ - --without-afids \ - --without-afids-data \ - --without-carto \ - --without-hdf5 \ - --disable-static \ - --without-documentation \ - PYTHON=/opt/conda/bin/python3.9 \ - GDAL_CONFIG=/opt/conda/bin/gdal-config && \ - make -j$(nproc) && \ - make install - -# ============================================================================ -# Runtime stage -# ============================================================================ -FROM ${BASE_IMAGE} - -LABEL org.opencontainers.image.title="Terrain Intelligence Generator + GeoCal" -LABEL org.opencontainers.image.description="TIG with GeoCal geometric calibration and bundle adjustment capabilities" -LABEL org.opencontainers.image.version="5.0-geocal" -LABEL org.opencontainers.image.source="https://github.com/NASA-AMMOS/tig" - -# Install runtime deps -RUN dnf install -y bzip2 && dnf clean all - -# Install micromamba for runtime -RUN curl -Ls https://micro.mamba.pm/api/micromamba/linux-64/latest | tar -xvj -C / bin/micromamba && \ - mkdir -p /opt/conda - -ENV MAMBA_ROOT_PREFIX=/opt/conda -ENV PATH="/opt/conda/bin:${PATH}" - -# Install runtime libs from conda-forge -RUN /bin/micromamba install -y -c conda-forge \ - gdal \ - boost-cpp \ - gsl \ - fftw \ - python=3.9 \ - numpy \ - scipy \ - matplotlib \ - -p /opt/conda && \ - /bin/micromamba clean -afy - -# Copy built artifacts from builder -COPY --from=builder /usr/local/cspice /usr/local/cspice -COPY --from=builder /usr/local/include/blitz /usr/local/include/blitz -COPY --from=builder /usr/local/lib/libblitz.* /usr/local/lib/ -COPY --from=builder /usr/local/geocal /usr/local/geocal - -# Set up environment -ENV PATH="/opt/conda/bin:/usr/local/geocal/bin:${PATH}" -ENV LD_LIBRARY_PATH="/opt/conda/lib:/usr/local/geocal/lib:/usr/local/lib:${LD_LIBRARY_PATH}" -ENV PYTHONPATH="/usr/local/geocal/lib/python3.9/site-packages:${PYTHONPATH}" - -# Update library cache -RUN ldconfig - -# Verify -RUN echo "=== VICAR RTL ===" && \ - ls -la /usr/local/vicar/dev/p2/lib/x86-64-linx/*.a | head -3 && \ - echo "=== GeoCal ===" && \ - ls -la /usr/local/geocal/lib/ | head -10 - -WORKDIR /work - -CMD ["/bin/bash"] diff --git a/build-geocal-image.sh b/build-geocal-image.sh deleted file mode 100755 index 10d1e9e..0000000 --- a/build-geocal-image.sh +++ /dev/null @@ -1,92 +0,0 @@ -#!/bin/bash -# Build script for TIG+GeoCal Docker image - -set -e - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -DOCKER_FILE="$SCRIPT_DIR/Dockerfile.geocal" - -# Default values -IMAGE_NAME="tig" -IMAGE_TAG="geocal" -GEOCAL_VERSION="e4c3cb071f3063352bd35b3048ddad1c077e10db" -PUSH_IMAGE=false - -# Parse arguments -while [[ $# -gt 0 ]]; do - case $1 in - --name) - IMAGE_NAME="$2" - shift 2 - ;; - --tag) - IMAGE_TAG="$2" - shift 2 - ;; - --geocal-version) - GEOCAL_VERSION="$2" - shift 2 - ;; - --push) - PUSH_IMAGE=true - shift - ;; - *) - echo "Unknown option: $1" - echo "Usage: $0 [--name IMAGE_NAME] [--tag IMAGE_TAG] [--geocal-version VERSION] [--push]" - exit 1 - ;; - esac -done - -FULL_IMAGE_NAME="${IMAGE_NAME}:${IMAGE_TAG}" - -echo "" -echo "╔════════════════════════════════════════════════════╗" -echo "║ Building TIG+GeoCal Image ║" -echo "╚════════════════════════════════════════════════════╝" -echo "" -echo "Image: ${FULL_IMAGE_NAME}" -echo "GeoCal version: ${GEOCAL_VERSION}" -echo "Push: ${PUSH_IMAGE}" -echo "" - -# Check if base image exists -echo -e "\e[32m✓\e[0m Checking for base TIG image..." -if ! docker image inspect ghcr.io/nasa-ammos/tig/terrain-intelligence-generator:opensource &> /dev/null; then - echo -e "\e[31m✗\e[0m Base image not found. Pulling..." - docker pull ghcr.io/nasa-ammos/tig/terrain-intelligence-generator:opensource -fi - -# Build the image -echo -e "\e[32m✓\e[0m Starting build (this will take 45-60 minutes)..." -echo "" - -if docker build \ - -f "${DOCKER_FILE}" \ - -t "${FULL_IMAGE_NAME}" \ - --build-arg GEOCAL_VERSION="${GEOCAL_VERSION}" \ - "$SCRIPT_DIR"; then - echo "" - echo -e "\e[32m✓\e[0m Build successful: ${FULL_IMAGE_NAME}" - - # Show image size - IMAGE_SIZE=$(docker images "${FULL_IMAGE_NAME}" --format "{{.Size}}") - echo -e "\e[32m✓\e[0m Image size: ${IMAGE_SIZE}" - - # Push if requested - if [ "$PUSH_IMAGE" = true ]; then - echo -e "\e[32m✓\e[0m Pushing image..." - docker push "${FULL_IMAGE_NAME}" - echo -e "\e[32m✓\e[0m Push complete" - fi - - echo "" - echo "To run the image:" - echo " docker run -it --rm ${FULL_IMAGE_NAME}" - echo "" -else - echo "" - echo -e "\e[31m✗\e[0m Build failed" - exit 1 -fi diff --git a/docs/geocal-integration-status.md b/docs/geocal-integration-status.md deleted file mode 100644 index 3fa87a4..0000000 --- a/docs/geocal-integration-status.md +++ /dev/null @@ -1,113 +0,0 @@ -# GeoCal Integration Status - -## Overview -This document tracks the status of integrating [GeoCal](https://github.com/Cartography-jpl/geocal) (geometric calibration and bundle adjustment) into the TIG environment. - -## Background -- **GeoCal**: Part of the AFIDS (Automated Feature Identification for Downlink System) cartography suite from JPL -- **Purpose**: Advanced geometric calibration, bundle adjustment, camera modeling for planetary/Earth imaging missions -- **Relationship to TIG**: Both systems use VICAR as foundation; GeoCal adds modern geometric processing capabilities - -## Current Status: **Build Complexity Blockers** - -### Attempted Approaches - -#### 1. ✗ Pre-built Conda Package -**Attempt**: Install `geocal` from conda-forge -**Result**: Package does not exist in conda-forge -**Notes**: AFIDS/GeoCal not published to public conda channels - -#### 2. ✗ Build from Source (gcc-toolset-10) -**Attempt**: Install gcc-toolset-10 on Oracle Linux 8 for C++17 support -**Result**: Build hung during 113MB+ toolset installation -**Blocker**: Resource constraints, long build time - -#### 3. ✗ Build from Source (micromamba + conda-forge compilers) -**Attempt**: Use micromamba + conda-forge's gxx_linux-64 for C++17 compiler -**Result**: Successfully built CSPICE and Blitz++, but GeoCal configure failed -**Blocker**: GeoCal's `configure.ac` has brittle GDAL version detection that fails with conda-installed GDAL: -``` -checking version GDAL library is new enough... no -configure: error: Need to have GDAL >= 1.9.2 -``` -Even with GDAL 3.x installed and explicit `GDAL_CONFIG` path provided. - -### Technical Challenges -1. **C++17 Requirement**: Oracle Linux 8 base has gcc 8.5 (too old), requires modern compiler -2. **Complex Dependencies**: CSPICE, Blitz++, Boost, GDAL, HDF5, VICAR RTL -3. **Autoconf Brittleness**: GeoCal's configure script has fragile dependency detection -4. **No Pre-built Binaries**: No official releases or conda packages available -5. **Build Time**: Multi-hour build expected (~45-60 min estimate was optimistic) - -## Recommended Path Forward - -### Option A: Wait for Upstream Improvements -- Request JPL/Cartography team publish conda packages or Docker images -- Wait for geocal CMake migration (more robust than autoconf) -- Track https://github.com/Cartography-jpl/geocal/issues - -### Option B: Separate GeoCal Environment -Rather than integrating into TIG base image: -1. Create separate `tig-geocal-dev` image based on conda-forge/miniforge3 -2. Install GeoCal from source with conda build tools -3. Install VICAR from TIG's pre-built binaries (extract from TIG image) -4. Link the two: use TIG for VICAR terrain processing, GeoCal for calibration -5. Provide data exchange scripts between environments - -### Option C: Minimal GeoCal Build -Focus on subset of GeoCal functionality: -1. Build only core geocal library (no Python wrappers initially) -2. Skip GDAL integration for first pass (limits functionality but avoids configure issues) -3. Use as C++ library only, called from VICAR programs -4. Expand gradually as build issues resolved - -### Option D: Document Integration for Users -Provide instructions for users to: -1. Clone geocal repo -2. Build locally (with detailed troubleshooting guide) -3. Mount into TIG container at runtime via `-v` bind mount -4. Set environment variables to link TIG VICAR + user's GeoCal - -## Files Created -- `Dockerfile.geocal` - Multi-stage build attempt (incomplete/non-functional) -- `build-geocal-image.sh` - Build script (not tested end-to-end) -- `docs/geocal-integration.md` - Original integration documentation (optimistic) -- `docs/geocal-integration-status.md` - This status document - -## Dependencies for Reference -From afids-conda-package analysis: -```yaml -# Core deps -- cspice >=N0067 # NASA SPICE toolkit -- blitz >=1.0.2 # C++ array library -- boost-cpp -- gdal >=3.0 -- hdf5 -- gsl -- fftw -- sqlite - -# Python deps -- python >=3.9 -- numpy -- scipy -- matplotlib -- pytest - -# Build deps -- cmake >=3.18 -- swig -- gcc >=11 (C++17) -- gfortran -``` - -## Next Steps -**Decision needed**: Which option (A, B, C, or D) to pursue? - -**Recommendation**: Option B (Separate GeoCal Environment) -- Cleanest separation of concerns -- TIG stays lean and focused on VICAR terrain processing -- GeoCal environment can evolve independently -- Data exchange via files (natural boundary for both systems) -- Users who need both get both; users who only need TIG aren't burdened - diff --git a/tig-cli/LICENSE b/tig-cli/LICENSE new file mode 100644 index 0000000..d511441 --- /dev/null +++ b/tig-cli/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 California Institute of Technology + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/tig-cli/README.md b/tig-cli/README.md index c7c61a0..b01ce13 100644 --- a/tig-cli/README.md +++ b/tig-cli/README.md @@ -1 +1,87 @@ # tig-cli + +Run [VICAR](https://github.com/nasa/VICAR) terrain-processing tools from your host shell, +executing them transparently inside the TIG Docker image. `tig-cli` handles container +lifecycle, X11 display forwarding, and host↔container path translation so VICAR commands +behave as if they ran locally. + +## Requirements + +- Python 3.9+ +- A running Docker daemon +- Access to a TIG VICAR image (defaults to the public open-source image) + +## Installation + +```bash +pip install tig-cli +``` + +Or from a checkout of this repository: + +```bash +cd tig-cli +pip install -e . +``` + +## Usage + +Invoke any VICAR tool by name, followed by its arguments: + +```bash +tig [args...] +``` + +Examples: + +```bash +# Run marsmap on a local file (relative paths work as-is) +tig marsmap input.vic output.vic + +# VICAR keyword=value arguments are passed through unchanged +tig marsmap INP=input.vic OUT=output.vic SIZE=(1,1,500,500) + +# Absolute paths outside your home directory are translated automatically +tig label /data/scenes/image.vic +``` + +### Options + +| Option | Description | +| --- | --- | +| `--writable-path PATH` | Mount an additional host directory read-write inside the container. May be repeated. | +| `--disable-path-translation` | Disable automatic host→container path translation (debugging). | +| `--help` | Show help, including the currently active container image. | + +### Configuration + +Set the `CONTAINER_IMAGE` environment variable to use a different VICAR image: + +```bash +export CONTAINER_IMAGE=ghcr.io/my-org/custom-vicar:latest +tig marsmap input.vic output.vic +``` + +## How path translation works + +- **Relative paths** are left unchanged. +- **Paths under your home directory** are mounted directly and left unchanged. +- **Other absolute paths** are prefixed with `/host` (the host root filesystem is + mounted read-only at `/host` inside the container). + +## Development + +```bash +cd tig-cli +pip install -e ".[dev]" + +# Run unit tests +pytest -m "not integration" + +# Run integration tests (requires Docker + a pullable TIG image) +pytest -m integration +``` + +## License + +Apache-2.0. See [LICENSE](LICENSE). From 8326755b0aa15b4afbe9741ebb73065ae9c9ae46 Mon Sep 17 00:00:00 2001 From: Jason Han Date: Tue, 4 Aug 2026 15:46:28 -0700 Subject: [PATCH 3/3] fix(tests): handle macOS /tmp symlink resolution in path translator test On macOS, /tmp is symlinked to /private/tmp. Path.resolve() returns the canonical path, causing test assertion to fail. Update test to expect resolved path for cross-platform compatibility. --- tig-cli/tests/test_path_translator.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tig-cli/tests/test_path_translator.py b/tig-cli/tests/test_path_translator.py index 87dad80..8a66187 100644 --- a/tig-cli/tests/test_path_translator.py +++ b/tig-cli/tests/test_path_translator.py @@ -27,8 +27,16 @@ def test_home_path_unchanged(translator, home_dir): def test_system_path_gets_host_prefix(translator): + import os + from pathlib import Path + + # Use /data which doesn't have symlink issues assert translator.translate("/data/file.vic") == "/host/data/file.vic" - assert translator.translate("/tmp/output.vic") == "/host/tmp/output.vic" + + # For /tmp, expect the resolved path (handles macOS /tmp -> /private/tmp) + tmp_resolved = str(Path("/tmp").resolve()) + expected = f"/host{tmp_resolved}/output.vic" + assert translator.translate("/tmp/output.vic") == expected def test_empty_path_unchanged(translator):