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:
--helpand 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
inspectorvalidate-pathutilities 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.
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
-
_require_rasterio()/_require_geopandas()— Each loader wraps its import intry/except ImportErrorand re-raises asclick.UsageError. Click catchesUsageError, prints the message without a traceback, and exits with code 2 — the POSIX convention for bad usage rather than a runtime error (exit 1). -
click.Path(path_type=Path)— Coerces the argument string topathlib.Pathat parse time. Downstream code usesPathmethods directly without manualstr()wrapping, which is the pattern favoured in the CLI subcommand organization guide for keeping command signatures clean. -
click.FloatRange(0.0, 1.0)— Validates the threshold at parse time so the lazy-loadedrasteriois never reached with an out-of-range value. This avoids a confusing processing error after a potentially slow import. -
rasterio.openin awithblock — Ensures the file handle and associated GDAL dataset are released even when an exception is thrown mid-processing. -
gdf.to_crs(epsg=epsg)called only when the source CRS differs — Avoids a redundant reprojection round-trip. Ifsrc.crsisNone(e.g. a raw image with no.prj), the output CRS is assumed from--epsgrather than raising. -
inspectcommand has zero GIS imports — It uses onlypathlib.Path.stat()and Click’s own output helpers. This guarantees thatpython gis_batch.py inspect ./data/works in a Dockerpython:3.12-slimimage 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 rasteriowas never run).OSError/rasterio.errors.NotGeoreferencedWarning/rasterio.errors.RasterioIOError—rasteriois 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.
Related
- Click vs Typer for Geospatial Workflows — parent guide covering the full framework comparison for spatial data pipelines
- Adding Auto-Completion to Python Spatial CLI Tools — shell completion setup that depends on Click initialising cleanly without GIS crashes