Article

Adding Auto-Completion to Python Spatial CLI Tools

Tab completion in a Typer spatial CLI requires three things: completer functions that resolve paths or EPSG codes in under 200 ms, binding those functions to parameters via shell_complete, and registering the generated script in the user’s shell config. Install typer[all] (which pulls in shellingham) and run --install-completion to activate. This page is part of the Argument Parsing with Typer guide under CLI Architecture & Design Patterns.

Prerequisites

  • Python 3.9+ with pip install "typer[all]" — the [all] extra includes shellingham (shell detection) and rich (help formatting)
  • A working Typer app; if you are starting from scratch, see How to Build a Typer CLI for Shapefile Conversion
  • Bash 4+, Zsh 5+, or Fish 3+ — the three shells Typer’s completion engine supports natively

No rasterio, geopandas, or GDAL is needed for completion itself; keep those imports out of completer functions entirely.

How the Completion Subprocess Works

Shell completion subprocess flow for a Typer spatial CLI User presses TAB; shell sets COMP_WORDS env var and spawns a fresh Python subprocess running the CLI in completion mode; the completer function returns a list of strings; the subprocess exits and the shell displays the suggestions. User presses TAB Shell sets COMP_WORDS & spawns subprocess Python subprocess runs completer fn (must finish <200 ms) Shell shows list isolated — no shared runtime state

The shell passes the partial command line via environment variables (COMP_WORDS, _TYPER_COMPLETE_ARGS), spawns your CLI as a subprocess, and collects its stdout within a hard timeout (typically 100–200 ms). Because the subprocess starts cold, any import that triggers GDAL driver registration or loads a large GeoDataFrame will silently blow past that limit — no error, just no suggestions.

Complete Working Implementation

The following module is self-contained. Copy it to spatial_cli.py, install the dependency, and run python spatial_cli.py --install-completion.

# spatial_cli.py
from __future__ import annotations

import functools
from pathlib import Path
from typing import List

import typer

app = typer.Typer(
    name="spatial-cli",
    help="Geospatial batch processing toolkit with shell completion.",
    add_completion=True,   # (1) exposes --install-completion and --show-completion
)

# ---------------------------------------------------------------------------
# Completer functions — must be pure, fast, and import-free of heavy GIS libs
# ---------------------------------------------------------------------------

SUPPORTED_FORMATS: List[str] = [
    "GeoTIFF", "Shapefile", "GeoPackage", "NetCDF", "GeoJSON", "FlatGeobuf"
]

# EPSG codes that cover the most common GIS CRS choices;
# extend or replace with a lightweight SQLite lookup for broader coverage.
EPSG_REGISTRY: List[str] = [
    "EPSG:4326",   # WGS 84 geographic
    "EPSG:3857",   # Web Mercator
    "EPSG:32633",  # UTM zone 33N
    "EPSG:32632",  # UTM zone 32N
    "EPSG:26918",  # UTM zone 18N (NAD83)
    "EPSG:27700",  # British National Grid
]

SPATIAL_EXTS = frozenset({".tif", ".tiff", ".shp", ".gpkg", ".geojson", ".nc", ".fgb"})


def complete_formats(ctx: typer.Context, param: typer.CallbackParam, incomplete: str) -> List[str]:  # (2)
    """Return spatial format names that start with the partial input."""
    return [f for f in SUPPORTED_FORMATS if f.lower().startswith(incomplete.lower())]


def complete_crs(ctx: typer.Context, param: typer.CallbackParam, incomplete: str) -> List[str]:
    """Return EPSG codes matching the partial string."""
    return [c for c in EPSG_REGISTRY if incomplete.upper() in c.upper()]


@functools.lru_cache(maxsize=64)   # (3) cache per unique parent-dir string
def _list_spatial_files(parent: str) -> List[str]:
    try:
        return [
            str(p) for p in Path(parent).iterdir()
            if p.suffix.lower() in SPATIAL_EXTS
        ]
    except (PermissionError, NotADirectoryError):
        return []


def complete_paths(ctx: typer.Context, param: typer.CallbackParam, incomplete: str) -> List[str]:
    """Suggest .tif/.shp/.gpkg/.geojson files relative to the partial path."""
    p = Path(incomplete)
    parent = str(p.parent) if p.parent != Path(".") else "."
    candidates = _list_spatial_files(parent)
    return [c for c in candidates if Path(c).name.startswith(p.name)]


# ---------------------------------------------------------------------------
# CLI command
# ---------------------------------------------------------------------------

@app.command()
def process(
    input_path: Path = typer.Argument(
        ...,
        help="Path to the input raster (.tif) or vector (.gpkg, .shp) dataset.",
        shell_complete=complete_paths,   # (4)
    ),
    output_format: str = typer.Option(
        "GeoTIFF",
        "--format", "-f",
        help="Output spatial format.",
        shell_complete=complete_formats,
    ),
    target_crs: str = typer.Option(
        "EPSG:4326",
        "--crs", "-c",
        help="Target coordinate reference system as an EPSG code.",
        shell_complete=complete_crs,
    ),
) -> None:
    """Reproject and convert a spatial dataset to the requested format and CRS."""
    typer.echo(f"Input : {input_path}")
    typer.echo(f"Format: {output_format}")
    typer.echo(f"CRS   : {target_crs}")
    # Replace this block with rasterio.open / pyogrio.read_dataframe logic.


if __name__ == "__main__":
    app()

Step Annotations

(1) add_completion=True is the default, but setting it explicitly makes the intent clear and is required if you ever construct the app with add_completion=False during testing.

(2) Completer signature — modern Typer (≥ 0.9) passes (ctx, param, incomplete). Older examples you may find online use only (incomplete,), which still works but skips context access. Prefer the three-argument form for forward compatibility.

(3) functools.lru_cache on _list_spatial_files caches the directory listing between rapid successive TAB presses. The cache key is the parent directory string, so scanning /data/project/ only triggers one iterdir() call per shell session rather than one per keystroke.

(4) shell_complete=complete_paths replaces the legacy autocompletion= keyword used in Click 7 and early Typer versions. Using autocompletion= on Typer ≥ 0.9 raises a DeprecationWarning and will be removed in a future release.

Registering the Completion Script

Interactive install (personal tooling)

python spatial_cli.py --install-completion
# Restart the shell or run:
source ~/.bashrc   # Bash
source ~/.zshrc    # Zsh

Typer uses shellingham to detect the active shell automatically and appends the source line to the correct config file.

Static export (team distribution)

# Export once and commit the file:
python spatial_cli.py --show-completion bash > completions/spatial_cli.bash
python spatial_cli.py --show-completion zsh  > completions/spatial_cli.zsh

# System-wide activation (Linux):
sudo cp completions/spatial_cli.bash /etc/profile.d/spatial_cli_completion.sh

For containerised environments or configuration-managed deployments, source the script from your Dockerfile’s shell config rather than relying on --install-completion inside the container build.

Named Gotcha: Missing shellingham Breaks Detection

Symptom: --install-completion exits silently without modifying any shell config file, or raises ImportError: No module named 'shellingham'.

Cause: Installing typer without extras (pip install typer) skips shellingham. Typer then cannot identify the active shell and writes nothing.

Fix:

pip install "typer[all]"
# Verify shellingham is present:
python -c "import shellingham; print(shellingham.detect_shell())"
# Expected output: ('bash', '/bin/bash') or ('zsh', '/usr/bin/zsh')

If you are in a locked virtual environment where you cannot change the install, pass the shell name explicitly:

python spatial_cli.py --install-completion bash
python spatial_cli.py --install-completion zsh

Verification

After sourcing the config file, confirm that all three parameter types resolve correctly:

# 1. Spatial file path completion — should list .tif/.gpkg/.shp files in ./data/
python spatial_cli.py process data/<TAB>

# 2. Format completion
python spatial_cli.py process data/input.tif --format Geo<TAB>
# Expected: GeoTIFF  GeoPackage  GeoJSON

# 3. CRS completion
python spatial_cli.py process data/input.tif --crs EPSG:32<TAB>
# Expected: EPSG:3257  EPSG:32633  EPSG:32632

# 4. Smoke-test the completion subprocess directly (Bash):
COMP_WORDS="spatial_cli process data/in" COMP_CWORD=2 \
  _TYPER_COMPLETE_ARGS="process data/in" \
  _SPATIAL_CLI_COMPLETE=bash_complete \
  python spatial_cli.py

If step 4 returns no output, the completer function is either raising an unhandled exception or timing out. Add a try/except Exception guard inside complete_paths and log to /tmp/comp_debug.log to capture the error without disturbing the shell.

Why does completion work in my terminal but not in a Docker container?

Containers built with FROM python:3.x-slim often lack the Bash bash-completion package. Install it in the Dockerfile (apt-get install -y bash-completion) and ensure your ENTRYPOINT or shell config sources /etc/profile.d/. Also confirm that shellingham can detect the shell inside the container — some minimal images replace /bin/bash with dash, which Typer does not support for completion.

Can I complete subcommand names as well as flags?

Yes — Typer generates subcommand completion automatically when you register sub-apps with app.add_typer(sub_app, name="subcommand"). No additional shell_complete callback is needed; the completion engine introspects the registered command tree at TAB time. For the broader subcommand structure, see CLI Subcommand Organization.

How do I surface EPSG codes from a live pyproj database without blocking the shell?

Build a one-time index at tool install time rather than at completion time. A post-install script can query pyproj.database.query_crs_info(), write all authority codes to a local SQLite file, and then your completer reads from that file with a fast LIKE query. A cold SQLite lookup over 6,000 EPSG codes completes in under 5 ms — well within the shell timeout. Refresh the index with a --rebuild-crs-cache flag users can run manually after updating pyproj.