Article

Structuring a Multi-Command GDAL CLI with Typer Sub-Apps

Split a growing GDAL CLI by giving each command group its own typer.Typer() instance in a separate module — commands/raster.py, commands/vector.py, commands/inspect.py — then mount them on one root app with app.add_typer(raster_app, name="raster"). That produces namespaced commands such as mytool raster warp and mytool vector reproject while keeping every module independently importable and testable. This page is part of the CLI Subcommand Organization for GIS Toolchains guide within the wider CLI Architecture & Design Patterns reference.

Prerequisites

  • Python 3.10 or later
  • pip install "typer>=0.12" pyogrio
  • gdal from a conda/mamba GDAL package or python3-gdal (GDAL 3.4+) for the raster commands

If you are still deciding how each command declares its options and arguments, read Argument Parsing with Typer first — this page assumes you already know how to annotate a single command and focuses purely on how many commands compose into one tool.

Why One App Per Group

A single-file CLI is fine at three commands. By the time a GDAL tool grows raster warping, vector reprojection, and CRS inspection, one flat typer.Typer() becomes a 600-line module where a syntax error in the vector code stops the raster commands from importing. The fix is to treat each command group as a self-contained package module that exports its own typer.Typer() app, and to keep the root app — the one your entry point calls — in a module that nothing else imports.

The diagram below shows the import direction that keeps this clean. Every arrow points toward main.py; nothing points back out. That one-way flow is what prevents circular imports.

Typer sub-app mounting and import direction Three command modules — raster, vector, and inspect — each expose a Typer app. The root main.py module imports all three and mounts them with add_typer under the names raster, vector, and inspect. Arrows show imports flowing from command modules into main.py only. commands/raster.py raster_app = Typer() commands/vector.py vector_app = Typer() commands/inspect.py inspect_app = Typer() import main.py app = Typer() app.add_typer(...) mytool raster warp vector reproject inspect crs namespaced tree

Complete Working Implementation

The package below has four files. Each command module is fully self-contained and exports exactly one typer.Typer() app; main.py is the only module that knows all three exist.

# mytool/commands/raster.py
"""Raster commands — exports raster_app, imports nothing from the root."""
from pathlib import Path
from typing import Annotated
import typer
from osgeo import gdal

raster_app = typer.Typer(no_args_is_help=True, help="Raster operations")


@raster_app.command("warp")
def warp(
    src: Annotated[Path, typer.Argument(help="Source GeoTIFF")],
    dst: Annotated[Path, typer.Argument(help="Reprojected output")],
    crs: Annotated[str, typer.Option(help="Target CRS")] = "EPSG:3857",
) -> None:
    """Reproject a raster to a web-mercator (EPSG:3857) grid."""
    gdal.UseExceptions()
    out = gdal.Warp(
        str(dst), str(src),
        dstSRS=crs,                 # e.g. "EPSG:3857"
        format="GTiff",
        creationOptions=["TILED=YES", "COMPRESS=LZW"],
        resampleAlg="bilinear",
    )
    if out is None:
        typer.secho(f"gdal.Warp produced no output for {src}", fg="red", err=True)
        raise typer.Exit(code=1)
    out = None                       # trigger GDALClose(), flush to disk
    typer.echo(f"warped {src.name} -> {dst.name} ({crs})")
# mytool/commands/vector.py
"""Vector commands — exports vector_app, uses pyogrio for I/O."""
from pathlib import Path
from typing import Annotated
import typer
import geopandas as gpd

vector_app = typer.Typer(no_args_is_help=True, help="Vector operations")


@vector_app.command("reproject")
def reproject(
    src: Annotated[Path, typer.Argument(help="Source vector file")],
    dst: Annotated[Path, typer.Argument(help="Reprojected output")],
    crs: Annotated[str, typer.Option(help="Target CRS")] = "EPSG:3857",
) -> None:
    """Reproject a vector layer, reading and writing through pyogrio."""
    gdf = gpd.read_file(src, engine="pyogrio")
    if gdf.crs is None:
        typer.secho(f"{src} has no CRS; cannot reproject", fg="red", err=True)
        raise typer.Exit(code=10)   # 10 = CRS mismatch/undefined
    out = gdf.to_crs(crs)
    out.to_file(dst, engine="pyogrio")
    typer.echo(f"reprojected {len(out)} features {gdf.crs.to_string()} -> {crs}")
# mytool/commands/inspect.py
"""Inspect commands — exports inspect_app, read-only."""
from pathlib import Path
from typing import Annotated
import typer
import geopandas as gpd

inspect_app = typer.Typer(no_args_is_help=True, help="Inspection helpers")


@inspect_app.command("crs")
def crs(
    src: Annotated[Path, typer.Argument(help="Vector or raster file")],
) -> None:
    """Print the authority code of a dataset's CRS."""
    info = gpd.read_file(src, engine="pyogrio", rows=1)
    code = info.crs.to_authority() if info.crs else None
    if code is None:
        typer.secho(f"{src}: no CRS defined", fg="yellow")
        raise typer.Exit(code=10)
    typer.echo(f"{src.name}: {code[0]}:{code[1]}")
# mytool/main.py
"""Root app — the ONLY module that imports the sub-apps."""
import typer
from mytool.commands.raster import raster_app
from mytool.commands.vector import vector_app
from mytool.commands.inspect import inspect_app

app = typer.Typer(no_args_is_help=True, help="A multi-command GDAL toolkit")

app.add_typer(raster_app, name="raster")     # -> mytool raster warp
app.add_typer(vector_app, name="vector")     # -> mytool vector reproject
app.add_typer(inspect_app, name="inspect")   # -> mytool inspect crs


def run() -> None:
    """Console-script entry point registered in pyproject.toml."""
    app()


if __name__ == "__main__":
    run()

Step Annotations

  1. One typer.Typer() per module — Each command file constructs its own app object and attaches commands to it with @raster_app.command(...). The module exports that object and nothing else the root needs. This is the whole reason a broken import in vector.py cannot take down raster.py.

  2. main.py imports downward only — The root imports raster_app, vector_app, and inspect_app; none of those modules import main.py. Keeping the arrow one-directional is what avoids the circular import failure described in the gotcha below.

  3. add_typer(raster_app, name="raster") — Mounting a sub-app prefixes its commands with a namespace. The warp command defined inside raster_app becomes mytool raster warp. The name lives at the mount point, so you can rename the namespace without touching the command module.

  4. no_args_is_help=True on every app — Applied to both the root and each sub-app, this makes a bare mytool, mytool raster, or mytool vector print help and exit 0 instead of raising a usage error and exiting 2.

  5. Domain exit codeswarp exits 1 on a GDAL runtime failure, while reproject and crs exit 10 when a CRS is missing. Consistent codes let shell wrappers and CI distinguish a genuine data problem from a crash.

  6. run() as the entry point — Registering mytool = "mytool.main:run" under [project.scripts] gives users the mytool command after pip install. Loading shared defaults here — for example from a project config file — lets every subcommand inherit them; see Configuration File Management for the layering pattern.

Named Gotcha: The Root App Import Cycle

The most common way this layout breaks is putting the root app = typer.Typer() object in the same module as a command that other modules need, then importing back and forth. If commands/raster.py does from mytool.main import app to register itself, and main.py does from mytool.commands.raster import raster_app, Python hits a partially initialised module during startup and raises ImportError: cannot import name 'app' from partially initialized module 'mytool.main'. On some import orders it fails silently with a sub-app that has zero commands.

The fix is the direction rule shown above: the root app lives alone in main.py, and command modules never import it. Each command module owns its own typer.Typer() and registers commands against that local object. The root pulls the finished sub-apps in and mounts them. Imports flow one way — from commands into the root — so there is no cycle to trip over.

Verification

Confirm every sub-app mounted under the expected namespace by walking the --help tree. The root help lists the three groups; each group help lists its commands:

# Root shows the three command groups
mytool --help | grep -E 'raster|vector|inspect'

# Each group resolves to its own commands
mytool raster --help | grep warp
mytool vector --help | grep reproject
mytool inspect --help | grep crs

# A real invocation end to end
mytool raster warp scene.tif scene_3857.tif --crs EPSG:3857
echo "exit: $?"        # 0 on success, 1 on GDAL failure

If mytool raster --help lists warp, the sub-app mounted correctly. A bare mytool raster should print the same help and exit 0 — if it exits 2 with a usage error, no_args_is_help=True is missing from that group’s constructor.

FAQ

Where should the root Typer app object live?

Put the root app in a dedicated main.py that imports each sub-app module. The sub-app modules must never import main.py. Keeping imports one-directional prevents the circular import that occurs when a command module and the root both reference each other at module load time.

Why does my group print an error instead of help when run with no arguments?

By default Typer exits with code 2 and a usage error when a command group is invoked without a subcommand. Pass no_args_is_help=True to each typer.Typer() constructor so bare invocations like mytool raster print the group help text and exit 0 instead.

How do I test one subcommand group in isolation?

Because each group is its own typer.Typer() instance, you can import just commands.raster and drive its app with typer.testing.CliRunner without loading vector or inspect. This keeps unit tests fast and their imports minimal, and it means a broken vector dependency never blocks raster tests.

Does add_typer change the command names inside a sub-app?

No. add_typer(raster_app, name="raster") only prefixes the namespace. A command defined as warp inside raster_app becomes mytool raster warp. The function names and their own command names are untouched, so you can mount the same sub-app under a different name without editing the commands.