Migrate a Click GIS CLI to Typer command-by-command rather than in one rewrite: Typer is built on Click, so a Typer app can mount your existing Click group with typer.main.get_command and keep serving unported commands unchanged. For each command you port, replace @click.option/@click.argument decorators with type-hinted parameters, convert ctx.exit(code) to raise typer.Exit(code) to preserve exit codes, and confirm behaviour with CliRunner. This page is part of the Click vs Typer for Geospatial Workflows guide within the broader CLI Architecture & Design Patterns reference.
Prerequisites
- Python 3.10 or later (for the
X | Noneandlist[...]hints used below) pip install "typer>=0.12" click— Typer pulls Click in as a dependency- A GDAL install providing
osgeo.gdal(GDAL 3.4+ from conda-forge orpython3-gdal) - An existing Click CLI whose parsing you already understand; the type-hint conventions carry over from Argument Parsing with Typer
The migration touches only the command-definition layer. Your reprojection logic, GDAL calls, and error taxonomy stay exactly as they are — you are swapping the parser, not the engine.
The Migration Path: Mount, Then Port
The safe strategy is not “convert everything” but “mount, then port one command at a time”. A Typer application compiles down to a Click Command, and the reverse is available too: typer.main.get_command(typer_app) hands you a Click object, while a raw Click group can be attached to a Typer app with app.add_typer(...) after wrapping it in a small typer.Typer shim, or served directly through a combined root group. The diagram below shows how the old and new command sets coexist under a single entry point during the transition.
Before: The Click Command
Here is the reproject command as it exists in the Click CLI. It takes a variadic list of input rasters, a required target CRS option, and an output directory, and it uses domain exit codes: 2 for usage errors, 10 for a CRS the driver rejects, 12 for a partial batch failure.
# gis_cli/click_app.py — BEFORE
import sys
from pathlib import Path
import click
from osgeo import gdal
gdal.UseExceptions()
@click.group()
def cli() -> None:
"""Legacy GDAL command group."""
@cli.command()
@click.argument("sources", nargs=-1, type=click.Path(exists=True, path_type=Path))
@click.option("--crs", "target_crs", required=True, help="Target CRS, e.g. EPSG:32633")
@click.option(
"--out-dir",
type=click.Path(file_okay=False, path_type=Path),
default=Path("./reprojected"),
show_default=True,
help="Output directory for warped rasters.",
)
@click.option("--overwrite/--no-overwrite", default=False, help="Replace existing outputs.")
def reproject(sources: tuple[Path, ...], target_crs: str, out_dir: Path, overwrite: bool) -> None:
"""Reproject one or more GeoTIFFs to TARGET_CRS."""
if not sources:
click.echo("No source rasters given.", err=True)
sys.exit(2) # usage error
out_dir.mkdir(parents=True, exist_ok=True)
failures = 0
for src in sources:
dst = out_dir / f"{src.stem}_{target_crs.replace(':', '_')}.tif"
if dst.exists() and not overwrite:
click.echo(f"skip (exists): {dst.name}")
continue
try:
ds = gdal.Warp(str(dst), str(src), dstSRS=target_crs, format="GTiff")
if ds is None:
raise RuntimeError("gdal.Warp returned no dataset")
ds = None # trigger GDALClose()
click.echo(f"ok: {dst.name}")
except RuntimeError as exc:
failures += 1
click.echo(f"fail: {src.name}: {exc}", err=True)
if failures == len(sources):
sys.exit(10) # every file rejected the CRS
if failures:
sys.exit(12) # partial batch failure
if __name__ == "__main__":
cli()
After: The Typer Command
The same command, ported to Typer. Every decorator becomes a type-hinted parameter with a typer.Option/typer.Argument default, sys.exit(code) becomes raise typer.Exit(code), and the nargs=-1 variadic becomes list[Path]. Crucially, the still-unported commands are mounted from the old Click group so nothing else breaks.
# gis_cli/typer_app.py — AFTER
from pathlib import Path
from typing import Annotated
import typer
from osgeo import gdal
from gis_cli.click_app import cli as legacy_click_group # commands not yet ported
gdal.UseExceptions()
app = typer.Typer(help="GDAL command group (Typer).", no_args_is_help=True)
@app.command()
def reproject(
sources: Annotated[
list[Path],
typer.Argument(exists=True, help="One or more source GeoTIFFs."),
],
target_crs: Annotated[
str, typer.Option("--crs", help="Target CRS, e.g. EPSG:32633")
],
out_dir: Annotated[
Path,
typer.Option("--out-dir", file_okay=False, help="Output directory."),
] = Path("./reprojected"),
overwrite: Annotated[
bool, typer.Option("--overwrite/--no-overwrite", help="Replace existing outputs.")
] = False,
) -> None:
"""Reproject one or more GeoTIFFs to the target CRS."""
if not sources:
typer.echo("No source rasters given.", err=True)
raise typer.Exit(2) # usage error — same code as before
out_dir.mkdir(parents=True, exist_ok=True)
failures = 0
for src in sources:
dst = out_dir / f"{src.stem}_{target_crs.replace(':', '_')}.tif"
if dst.exists() and not overwrite:
typer.echo(f"skip (exists): {dst.name}")
continue
try:
ds = gdal.Warp(str(dst), str(src), dstSRS=target_crs, format="GTiff")
if ds is None:
raise RuntimeError("gdal.Warp returned no dataset")
ds = None # trigger GDALClose()
typer.echo(f"ok: {dst.name}")
except RuntimeError as exc:
failures += 1
typer.echo(f"fail: {src.name}: {exc}", err=True)
if failures == len(sources):
raise typer.Exit(10) # every file rejected the CRS
if failures:
raise typer.Exit(12) # partial batch failure
# Mount the not-yet-ported Click commands so the whole app keeps working.
# typer.main.get_command(app) -> Click object for the Typer commands;
# we merge that with the legacy Click group under one root.
typer_as_click = typer.main.get_command(app)
root = typer.main.get_group(app) if hasattr(typer.main, "get_group") else typer_as_click
for name, cmd in legacy_click_group.commands.items():
if name not in root.commands: # never shadow a ported command
root.commands[name] = cmd
if __name__ == "__main__":
root()
Step Annotations
-
@click.argument("sources", nargs=-1)becomeslist[Path]— Typer derives multiplicity from the annotation, not a keyword. Alist[Path]parameter with atyper.Argumentdefault accepts any number of values; there is nonargsto pass. This is the mapping to internalise before touching any other parameter. -
@click.option("--crs", "target_crs", required=True)becomes anAnnotated[str, typer.Option("--crs", ...)]with no default — a parameter without a default value is required in Typer. Passing the explicit"--crs"string preserves the flag name; without it Typer would derive--target-crsfrom the Python name and silently change your public interface. -
show_default=Truedisappears — Typer shows defaults automatically for optional parameters, so the flag is dropped. Thedefault=Path("./reprojected")moves to the parameter’s= Path(...)default in the signature. -
--overwrite/--no-overwriteboolean flag — Click’s slash syntax works unchanged when passed as the first argument totyper.Option. Typer recognises the/and builds the paired on/off flags identically. -
sys.exit(code)becomesraise typer.Exit(code)— this is the load-bearing substitution for behaviour preservation.typer.Exitcarries the integer straight to the process exit status, so2,10, and12remain exactly what callers and CI pipelines match on. Never leave a baresys.exitinside a Typer command: it works but bypasses Typer’s result-handling and complicates testing withCliRunner. -
typer.main.get_command(app)and merginglegacy_click_group.commands— because Typer compiles to Click, both command sets are plain ClickCommandobjects living in a.commandsdict. Copying the legacy entries into the root group mounts them under one entry point. Theif name not in root.commandsguard guarantees a ported command always wins over its legacy twin, so you can portreprojectand delete the old one later without a flag day.
Named Gotcha: The Callback Signature Changes Between Click and Typer
The most common breakage during this port is a group-level callback that used the Click @click.pass_context idiom to stash shared state (a config path, a --verbose flag, a pyproj Transformer) on ctx.obj. In Click the callback receives a Context as its first positional parameter. In Typer, the group callback is an ordinary function whose parameters are parsed as options; if you keep a bare ctx parameter, Typer tries to turn it into a CLI option and raises a type error at import time, because click.Context is not a supported parameter type.
The fix is to annotate the context explicitly so Typer injects it instead of parsing it:
@app.callback()
def main(
ctx: typer.Context, # explicit annotation -> injected, not parsed
verbose: Annotated[bool, typer.Option("--verbose")] = False,
) -> None:
ctx.obj = {"verbose": verbose}
Typer special-cases a parameter annotated as typer.Context (an alias of click.Context) and passes the live context through untouched, so ctx.obj assignment and downstream ctx.obj["verbose"] reads keep working exactly as they did under Click. Miss the annotation and the app will not even import.
Verification
Test the ported command against the Click baseline with typer.testing.CliRunner. It wraps Click’s runner, so result.exit_code reports the same integers your raise typer.Exit(code) calls produce. Assert on the exit codes that carry domain meaning and confirm --help still lists every command — both ported and mounted:
# tests/test_reproject_migration.py
from typer.testing import CliRunner
from gis_cli.typer_app import app
runner = CliRunner()
def test_usage_error_exit_code() -> None:
# No SOURCES given -> usage error, exit code 2 (unchanged from Click).
result = runner.invoke(app, ["reproject", "--crs", "EPSG:32633"])
assert result.exit_code == 2
assert "No source rasters" in result.output
def test_help_lists_all_commands() -> None:
result = runner.invoke(app, ["--help"])
assert result.exit_code == 0
assert "reproject" in result.output # ported Typer command
assert "validate" in result.output # still mounted from Click
Run the suite and a manual completion check side by side:
pytest tests/test_reproject_migration.py -q
# Shell completion is shared because Typer compiles to Click:
python -m gis_cli.typer_app --install-completion bash
python -m gis_cli.typer_app reproject --crs EPSG:4326 ./tiles/*.tif
echo "exit code: $?" # expect 0 on full success, 12 on partial failure
Matching exit codes from CliRunner and a --help listing that still contains the mounted commands confirm the migration preserved behaviour. For the broader testing harness these assertions plug into, see Testing Click Commands with CliRunner for GIS Tools.
FAQ
Do I have to rewrite the whole CLI in one commit?
No. Typer is built on Click, so a Typer app can mount an existing Click group with typer.main.get_command and add_typer. Port one command at a time, keep the rest running as Click, and ship each command independently.
How do I keep exit codes identical after migrating to Typer?
Replace ctx.exit(code) and sys.exit(code) with raise typer.Exit(code). typer.Exit propagates the integer to the process exit status unchanged, so domain codes like 10 for a CRS mismatch and 2 for a usage error survive the port.
What happens to a Click nargs=-1 variadic argument in Typer?
A Click argument with nargs=-1 becomes a parameter typed as list[Path] with a typer.Argument default in Typer. Typer reads the number of values from the type annotation, so there is no nargs keyword to pass.
Will shell completion keep working during the migration?
Yes. Because Typer compiles to a Click command object, the completion machinery is shared. Ported Typer commands and mounted Click commands both appear under the same --install-completion hook, so a single installed completion script covers the whole app.
Related
- Click vs Typer for Geospatial Workflows — parent guide weighing the two parsers for GDAL and pyproj command groups
- Testing Click Commands with CliRunner for GIS Tools — assert exit codes and output for both Click and Typer commands during the port