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. The Click vs Typer for Geospatial Workflows guide covers this pattern alongside the wider framework comparison.
Prerequisites
- Python 3.9+,
click>=8.1 - No GIS package required at import time β that is the point of this pattern
- See CLI Architecture & Design Patterns for the wider design context
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.
The distinction that matters is when the import runs. Anything at module scope executes before
Click has parsed a single token, which is why a broken GDAL install turns --help into a traceback.
The deferred version also keeps --version and shell completion alive on a broken machine, which
is exactly when a user most needs to know which build they are running.
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.
The three exception families a geospatial import can raise look nothing alike, and catching only
ImportError leaves the most common production failure uncaught.
The third column is the awkward one, because nothing fails at import time at all β the tool starts, accepts arguments, and only dies once a transform touches the database.
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