Article

Handling Missing Dependencies Gracefully in Click Apps

Wrap each compiled geospatial import in a dedicated lazy-loader function and call that function inside the command body rather than at module level. When the import fails, raise click.UsageError with an install hint. This keeps --help, tab completion, and pure-Python subcommands fully operational in environments where rasterio, GDAL, or geopandas are absent — a key resilience pattern within the broader Click vs Typer for Geospatial Workflows guide.

Prerequisites

  • Python 3.9+, click>=8.1
  • No GIS package required at import time — that is the point of this pattern
  • For broader CLI design context see CLI Architecture & Design Patterns

Why Geospatial CLIs Crash Before Argument Parsing

Compiled extensions (rasterio, GDAL, shapely, pyproj) load shared libraries (.so / .dll) the moment Python encounters the import statement. If the host environment lacks a compatible libgdal or ABI-matched wheel, Python raises ImportError or OSError before Click has had a chance to build its command router.

The result is that three developer workflows break simultaneously:

  • --help and subcommand discovery fail, blocking onboarding and self-documentation.
  • Shell completion scripts crash at tab-press, degrading the experience for power users who rely on adding auto-completion to Python spatial CLI tools.
  • Container portability collapses — lightweight base images cannot run inspect or validate-path utilities that have no real GIS dependency at all.

The diagram below shows how a module-level import fails the entire process versus how a lazy loader isolates the failure to the one command that actually needs the library.

Lazy Import vs Module-Level Import in a Click CLI Left side shows module-level import crashing before Click initialises. Right side shows lazy import isolating the failure inside the command body, leaving help and other commands intact. Module-level import (bad) import rasterio ← top of file raises ImportError / OSError immediately Click never initialises --help fails tab completion crashes all subcommands unavailable Lazy import (correct) Click initialises successfully no GIS import at module level --help works inspect cmd no GIS needed raster-to-vector lazy-loads rasterio ImportError → click.UsageError clean message, exit 2

Complete Working Implementation

The snippet below is self-contained. Copy it as gis_batch.py, run python gis_batch.py --help in an environment that lacks rasterio, and verify that help text displays correctly.

#!/usr/bin/env python3
"""
gis_batch.py — Click CLI with graceful GIS dependency handling.
Tested against: click>=8.1, Python 3.9+
"""
from __future__ import annotations

import os
import sys
from pathlib import Path

import click


# ---------------------------------------------------------------------------
# Lazy loaders — called INSIDE command bodies, never at module level
# ---------------------------------------------------------------------------

def _require_rasterio():
    """Return the rasterio module or raise click.UsageError with install hint."""
    try:
        import rasterio          # noqa: PLC0415  (intentional lazy import)
        return rasterio
    except ImportError as exc:
        raise click.UsageError(
            f"rasterio is not installed: {exc}\n"
            "Install: pip install rasterio  "
            "or for conda: conda install -c conda-forge rasterio"
        ) from exc


def _require_geopandas():
    """Return the geopandas module or raise click.UsageError with install hint."""
    try:
        import geopandas as gpd  # noqa: PLC0415
        return gpd
    except ImportError as exc:
        raise click.UsageError(
            f"geopandas is not installed: {exc}\n"
            "Install: pip install geopandas"
        ) from exc


# ---------------------------------------------------------------------------
# CLI group
# ---------------------------------------------------------------------------

@click.group()
@click.version_option("2.1.0")
def gis_batch() -> None:
    """Geospatial batch processing toolkit.

    Core commands (raster-to-vector, reproject) require rasterio and
    geopandas. The `inspect` command runs with the standard library only.
    """


# ---------------------------------------------------------------------------
# Heavy command — lazy-loads compiled extensions
# ---------------------------------------------------------------------------

@gis_batch.command("raster-to-vector")
@click.argument("input_raster", type=click.Path(exists=True, path_type=Path))
@click.option(
    "--threshold", "-t",
    type=click.FloatRange(0.0, 1.0),
    default=0.5,
    show_default=True,
    help="Binary threshold applied to band 1.",
)
@click.option(
    "--output", "-o",
    type=click.Path(path_type=Path),
    required=True,
    help="Destination GeoPackage path (.gpkg).",
)
@click.option(
    "--epsg",
    type=int,
    default=4326,
    show_default=True,
    help="Output CRS as an EPSG integer (e.g. 32632 for UTM zone 32N).",
)
def raster_to_vector(
    input_raster: Path,
    threshold: float,
    output: Path,
    epsg: int,
) -> None:
    """Vectorise a raster mask and write polygons to a GeoPackage.

    Requires: rasterio, geopandas, shapely>=2.0
    """
    # Lazy-load heavy dependencies — ImportError becomes a clean UsageError
    rasterio = _require_rasterio()
    gpd = _require_geopandas()
    from shapely.geometry import shape  # noqa: PLC0415

    try:
        with rasterio.open(input_raster) as src:
            band = src.read(1)                     # read first band into ndarray
            transform = src.transform              # affine transform for vectorisation
            src_crs = src.crs                      # source CRS (may be None for raw TIFFs)

        # Threshold: pixels > threshold become polygon candidates
        mask = (band > threshold).astype("uint8")

        from rasterio.features import shapes as rio_shapes  # noqa: PLC0415
        polys = [
            {"geometry": shape(geom), "value": float(val)}
            for geom, val in rio_shapes(mask, transform=transform)
            if val == 1.0
        ]

        if not polys:
            raise click.ClickException(
                f"No pixels above threshold {threshold} found in {input_raster}"
            )

        gdf = gpd.GeoDataFrame(polys, crs=src_crs or f"EPSG:{epsg}")

        # Re-project to the requested output CRS
        if gdf.crs and gdf.crs.to_epsg() != epsg:
            gdf = gdf.to_crs(epsg=epsg)

        gdf.to_file(output, driver="GPKG", layer="polygons")
        click.echo(f"Wrote {len(gdf)} polygons → {output}  (EPSG:{epsg})")

    except (OSError, rasterio.errors.RasterioIOError) as exc:
        raise click.ClickException(f"Raster read failed: {exc}") from exc


# ---------------------------------------------------------------------------
# Lightweight command — pure stdlib, works in any environment
# ---------------------------------------------------------------------------

@gis_batch.command()
@click.argument("path", type=click.Path(exists=True, path_type=Path))
def inspect(path: Path) -> None:
    """Show file metadata without requiring any GIS package."""
    stat = path.stat()
    click.echo(f"path     : {path.resolve()}")
    click.echo(f"size     : {stat.st_size:,} bytes")
    click.echo(f"modified : {stat.st_mtime:.0f}")
    click.echo(f"suffix   : {path.suffix or '(none)'}")


# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------

if __name__ == "__main__":
    gis_batch()

Step Annotations

  1. _require_rasterio() / _require_geopandas() — Each loader wraps its import in try/except ImportError and re-raises as click.UsageError. Click catches UsageError, prints the message without a traceback, and exits with code 2 — the POSIX convention for bad usage rather than a runtime error (exit 1).

  2. click.Path(path_type=Path) — Coerces the argument string to pathlib.Path at parse time. Downstream code uses Path methods directly without manual str() wrapping, which is the pattern favoured in the CLI subcommand organization guide for keeping command signatures clean.

  3. click.FloatRange(0.0, 1.0) — Validates the threshold at parse time so the lazy-loaded rasterio is never reached with an out-of-range value. This avoids a confusing processing error after a potentially slow import.

  4. rasterio.open in a with block — Ensures the file handle and associated GDAL dataset are released even when an exception is thrown mid-processing.

  5. gdf.to_crs(epsg=epsg) called only when the source CRS differs — Avoids a redundant reprojection round-trip. If src.crs is None (e.g. a raw image with no .prj), the output CRS is assumed from --epsg rather than raising.

  6. inspect command has zero GIS imports — It uses only pathlib.Path.stat() and Click’s own output helpers. This guarantees that python gis_batch.py inspect ./data/ works in a Docker python:3.12-slim image with no extras installed.

Named Gotcha: OSError from GDAL vs ImportError from Python

The most common failure mode is confusing the two error types:

  • ImportError — the Python package is not installed at all (pip install rasterio was never run).
  • OSError / rasterio.errors.NotGeoreferencedWarning / rasterio.errors.RasterioIOErrorrasterio is installed but the underlying GDAL shared library (libgdal.so) is missing or ABI-incompatible.

Fix: catch both at the appropriate layer. The lazy loader catches ImportError only. The command body catches OSError and RasterioIOError separately, re-raising each as click.ClickException. Never swallow both into a single bare except Exception — that hides actionable diagnostics.

def _require_rasterio():
    try:
        import rasterio
        return rasterio
    except ImportError as exc:            # package absent
        raise click.UsageError(f"rasterio not installed: {exc}") from exc
    # OSError (missing libgdal) surfaces at open() time, not import time,
    # so handle it in the command body, not here.

Verification

Run the following shell commands to confirm all three paths work correctly:

# 1. Help text works with no GIS packages installed
python gis_batch.py --help

# 2. Inspect command works with no GIS packages
python gis_batch.py inspect ./some_file.tif

# 3. Simulate missing rasterio in pytest
python -c "
import unittest.mock, sys
with unittest.mock.patch.dict(sys.modules, {'rasterio': None}):
    from gis_batch import _require_rasterio
    import click
    try:
        _require_rasterio()
    except click.UsageError as e:
        print('PASS — UsageError raised:', e)
"

# 4. Confirm exit code 2 (UsageError) vs 1 (ClickException)
python gis_batch.py raster-to-vector nonexistent.tif -o out.gpkg; echo "exit: $?"

Expected output for step 3: PASS — UsageError raised: rasterio is not installed …

Expected exit code for step 4: 2 (Click maps UsageError to exit code 2).


Why does click.UsageError exit with code 2 rather than 1?

POSIX convention: exit 0 = success, exit 1 = general runtime error, exit 2 = misuse of the command. Click maps UsageError — which covers bad arguments, missing options, and environment problems like an absent dependency — to exit 2, matching the behaviour of standard Unix tools (ls, cp). ClickException uses exit 1 for runtime failures where usage was correct but the operation failed.

Should lazy loaders cache the module reference?

For one-shot CLI invocations the overhead of re-importing is negligible — Python’s import system caches modules in sys.modules after the first load. The lazy-loader function only pays the try/except overhead on repeated calls; the actual shared-library loading happens once. For long-running daemon processes that call a lazy loader in a hot loop, assign the return value to a module-level variable after the first successful call.

Does Typer support the same lazy-loading pattern?

Yes. Typer wraps Click and surfaces the same click.UsageError and click.ClickException types, so the identical lazy-loader functions work unchanged. The only difference is that Typer infers parameter types from annotations, adding a small parse-time overhead even before a command body runs. For startup-sensitive tooling see the Click vs Typer for Geospatial Workflows comparison for a quantified breakdown.

How do I enforce no module-level GIS imports across a team?

Add a ruff rule to the project’s pyproject.toml. The PLC0415 rule (import-outside-top-level) usually flags what you want to allow in lazy loaders; use a # noqa: PLC0415 comment on those intentional deferred imports and enable the rule globally. A CI job that runs ruff check --select PLC0415 src/ without the noqa allowlist will catch accidental top-level GIS imports.