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" pyogriogdalfrom a conda/mamba GDAL package orpython3-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.
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
-
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 invector.pycannot take downraster.py. -
main.pyimports downward only — The root importsraster_app,vector_app, andinspect_app; none of those modules importmain.py. Keeping the arrow one-directional is what avoids the circular import failure described in the gotcha below. -
add_typer(raster_app, name="raster")— Mounting a sub-app prefixes its commands with a namespace. Thewarpcommand defined insideraster_appbecomesmytool raster warp. The name lives at the mount point, so you can rename the namespace without touching the command module. -
no_args_is_help=Trueon every app — Applied to both the root and each sub-app, this makes a baremytool,mytool raster, ormytool vectorprint help and exit0instead of raising a usage error and exiting2. -
Domain exit codes —
warpexits1on a GDAL runtime failure, whilereprojectandcrsexit10when a CRS is missing. Consistent codes let shell wrappers and CI distinguish a genuine data problem from a crash. -
run()as the entry point — Registeringmytool = "mytool.main:run"under[project.scripts]gives users themytoolcommand afterpip 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.
Related
- CLI Subcommand Organization for GIS Toolchains — parent guide covering how to group, name, and namespace commands as a GDAL tool grows
- Sharing Global Options Across Geospatial Subcommands — pass verbosity, config paths, and CRS defaults down into every mounted sub-app