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. It builds on the Argument Parsing with Typer guide, part of the wider CLI Architecture & Design Patterns reference.

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.

A completion request is a second, hidden invocation of your own program. Seeing the round trip explains both the latency budget and why a slow module-level import ruins the experience.

What happens between the tab key and the candidate list Four lifelines. The user presses tab. The shell invokes the registered completion hook. The hook re-executes the CLI process with a completion environment variable set. The CLI imports, computes candidates and prints them. The shell renders the list. user shell completion hook your CLI TAB runs registered hook _GIS_COMPLETE set import, then list candidates on stdout list rendered

The shaded band on the right is the whole budget. Every import your entry point performs at module scope happens inside it, which is why a top-level import geopandas turns a snappy completion into a visible stall.

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.

Each shell looks for the generated script in a different place, and the reload ritual differs too. The table below is the one worth pinning to the install docs.

Install location and reload step per shell Three rows. Bash reads scripts from the bash-completion directory and reloads by re-sourcing the profile. Zsh reads a underscore-prefixed file on the fpath and reloads via compinit. Fish reads a fish file from its completions directory and picks it up on the next prompt. shell where the script lives picked up when bash ~/.bash_completions/gis.sh profile is re-sourced zsh a _gis file anywhere on $fpath compinit runs again fish ~/.config/fish/completions/gis.fish the next prompt

Fish is the only one of the three that needs no reload step, which makes it the fastest shell to test a completion change against while you are iterating.

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.


Completing values, not just flag names

Completing option names is the easy half. What makes completion feel worth installing on a geospatial tool is completing the values — the EPSG codes the project actually uses, the layer names inside the GeoPackage the user just typed, the driver short names this GDAL build supports. Typer supports this through autocompletion= on an option, which receives the partial token and returns candidates.

The constraint is time. The shell will wait for your process, and a completion that takes half a second feels broken. That rules out anything that opens a remote dataset or queries a database on every keystroke. Two patterns keep it fast: complete from a cached list written at install time or on first use, and complete from information that is already local — the contents of a directory, the layers in a file the user has already named on the command line.

It is also worth completing conservatively. Returning two hundred EPSG codes is not help; returning the six the project’s config file mentions, plus any code the user has already typed once, is. A completion callback that reads the resolved configuration is both faster and more useful than one that enumerates the PROJ database, and it degrades gracefully when no configuration is present.