Article

Migrating a Click Geospatial CLI to Typer

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 | None and list[...] 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 or python3-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.

Incremental Click-to-Typer migration under one entry point A root CLI box at the top branches to three commands. The reproject command points to a green ported Typer implementation; the validate and info commands point to an amber mounted Click group. A dashed arrow shows commands moving from Click to Typer over time. gis (root Typer app) single entry point reproject (ported) validate (mounted) info (mounted) Typer type hints typer.Option / Argument legacy Click group typer.main.get_command bridge commands move left over time

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

  1. @click.argument("sources", nargs=-1) becomes list[Path] — Typer derives multiplicity from the annotation, not a keyword. A list[Path] parameter with a typer.Argument default accepts any number of values; there is no nargs to pass. This is the mapping to internalise before touching any other parameter.

  2. @click.option("--crs", "target_crs", required=True) becomes an Annotated[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-crs from the Python name and silently change your public interface.

  3. show_default=True disappears — Typer shows defaults automatically for optional parameters, so the flag is dropped. The default=Path("./reprojected") moves to the parameter’s = Path(...) default in the signature.

  4. --overwrite/--no-overwrite boolean flag — Click’s slash syntax works unchanged when passed as the first argument to typer.Option. Typer recognises the / and builds the paired on/off flags identically.

  5. sys.exit(code) becomes raise typer.Exit(code) — this is the load-bearing substitution for behaviour preservation. typer.Exit carries the integer straight to the process exit status, so 2, 10, and 12 remain exactly what callers and CI pipelines match on. Never leave a bare sys.exit inside a Typer command: it works but bypasses Typer’s result-handling and complicates testing with CliRunner.

  6. typer.main.get_command(app) and merging legacy_click_group.commands — because Typer compiles to Click, both command sets are plain Click Command objects living in a .commands dict. Copying the legacy entries into the root group mounts them under one entry point. The if name not in root.commands guard guarantees a ported command always wins over its legacy twin, so you can port reproject and 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.