Article

Accepting Bounding Box Arguments in a Typer CLI

Take the extent as one comma-separated --bbox option, parse it in a callback into a validated dataclass, and check ordering and CRS domain there rather than in the command body. A reversed or out-of-domain box then fails with a usage message and exit code 2, before a single byte is read. It builds on the Argument Parsing with Typer guide, part of the broader CLI Architecture & Design Patterns reference.

Prerequisites

Four Numbers, Several Conventions

The hardest part is not parsing — it is that the same four numbers mean different things in different tools, and getting it wrong produces a plausible empty result rather than an error.

The same four numbers, four meanings The most common order is minx miny maxx maxy. gdal_translate's projwin uses upper-left and lower-right instead. Some geographic tools put latitude first. WMS 1.3 orders axes according to the CRS definition, which flips for many geographic codes. minx miny maxx maxy the ordinary GDAL and shapely convention use this ulx uly lrx lry gdal_translate -projwin — y descends document if you accept it miny minx maxy maxx latitude first, seen in some geographic tools reject explicitly axis order from the CRS WMS 1.3 — flips for many geographic codes never infer it

Accepting exactly one convention and naming it in the help text is worth more than any amount of clever detection. A tool that guesses will guess wrong for somebody, silently.

The three checks the parser performs are ordered so that each one can assume the previous passed, which keeps every error message about one thing.

Three checks, in the only order that gives clear messages The field count is checked first, because a conversion error on three values would be misleading. Numeric conversion comes next, naming the offending token. Ordering is checked last, once four numbers definitely exist. exactly four values a count error is clearer than a conversion error checked first all four are numbers names the token that failed to convert checked second minx < maxx, miny < maxy only meaningful once four numbers exist checked last

Reversing any two produces a message about the wrong problem — a reversed box reported as a conversion failure, or a three-value box reported as an ordering error.

Complete Working Implementation

# gistools/bbox.py
from __future__ import annotations

from dataclasses import dataclass

import typer
from pyproj import CRS


@dataclass(frozen=True)
class BBox:
    minx: float
    miny: float
    maxx: float
    maxy: float

    def as_tuple(self) -> tuple[float, float, float, float]:
        return (self.minx, self.miny, self.maxx, self.maxy)

    def check_domain(self, crs: CRS) -> None:
        """Reject a box that cannot exist in `crs`."""
        area = crs.area_of_use
        if area is None:
            return
        west, south, east, north = area.bounds
        if crs.is_geographic:
            if not (-180 <= self.minx <= 180 and -180 <= self.maxx <= 180):
                raise typer.BadParameter(
                    f"longitude out of range in --bbox: {self.minx}, {self.maxx}")
            if not (-90 <= self.miny <= 90 and -90 <= self.maxy <= 90):
                raise typer.BadParameter(
                    f"latitude out of range in --bbox: {self.miny}, {self.maxy}")
            if not (west <= self.minx <= east and south <= self.miny <= north):
                raise typer.BadParameter(
                    f"--bbox lies outside the area of use for {crs.to_string()} "
                    f"({west}, {south}, {east}, {north})")


def parse_bbox(value: str | None) -> BBox | None:
    if value is None:
        return None
    parts = [p.strip() for p in value.split(",")]
    if len(parts) != 4:
        raise typer.BadParameter(
            "--bbox needs exactly four values: minx,miny,maxx,maxy")
    try:
        minx, miny, maxx, maxy = (float(p) for p in parts)
    except ValueError as exc:
        raise typer.BadParameter(f"--bbox values must be numbers: {exc}") from exc
    if minx >= maxx:
        raise typer.BadParameter(f"--bbox minx ({minx}) must be less than maxx ({maxx})")
    if miny >= maxy:
        raise typer.BadParameter(f"--bbox miny ({miny}) must be less than maxy ({maxy})")
    return BBox(minx, miny, maxx, maxy)

Wiring it into a command:

app = typer.Typer()


@app.command()
def clip(
    src: Path = typer.Argument(..., exists=True, dir_okay=False),
    dst: Path = typer.Argument(...),
    bbox: BBox = typer.Option(
        ..., "--bbox", parser=parse_bbox, metavar="MINX,MINY,MAXX,MAXY",
        help="Extent to clip to, in --bbox-crs. Order: minx,miny,maxx,maxy.",
    ),
    bbox_crs: str = typer.Option(
        "EPSG:4326", "--bbox-crs",
        help="CRS the --bbox values are expressed in.",
    ),
) -> None:
    crs = CRS.from_user_input(bbox_crs)
    bbox.check_domain(crs)
    ...

Step Annotations

  1. parser= rather than a callback=. A parser returns the converted value, so the command body receives a BBox and not a string it has to re-parse — the mapping described in Migrating a Click Geospatial CLI to Typer.
  2. metavar spells out the order. It appears in the usage line, which is where someone actually looks before typing the option.
  3. A separate --bbox-crs with a default. Inferring the box’s CRS from the source is a trap: a user clipping a British National Grid raster almost always wants to give the extent in degrees.
  4. The ordering check uses >=, not >. A zero-width box is degenerate and produces an empty output rather than an error, which is the failure this is meant to prevent.
  5. check_domain is called in the body, not the parser. It needs --bbox-crs, and a parser cannot see other options — parsers run per option, independently.

One Named Gotcha: A Reprojected Box Is Not a Box

Transforming the four corners of a bounding box into another CRS and taking their extent understates the true extent whenever the projection curves — which is most of them, over any distance. The clip then silently drops a sliver along the edges.

from pyproj import Transformer

# Wrong: four corners only.
transformer = Transformer.from_crs(src_crs, dst_crs, always_xy=True)
minx, miny = transformer.transform(bbox.minx, bbox.miny)
maxx, maxy = transformer.transform(bbox.maxx, bbox.maxy)

# Right: densify the edges before transforming.
from rasterio.warp import transform_bounds
minx, miny, maxx, maxy = transform_bounds(
    src_crs, dst_crs, *bbox.as_tuple(), densify_pts=21)

transform_bounds with densify_pts samples along each edge and takes the extent of the samples, which is correct for any projection. The error from the naive version is small enough to miss in review and large enough to lose a kilometre at continental scale.

Verification

# Each invalid form fails with exit code 2 and a specific message.
gis clip in.tif out.tif --bbox "1,2,3"            # too few values
gis clip in.tif out.tif --bbox "3,2,1,4"          # minx >= maxx
gis clip in.tif out.tif --bbox "-200,0,10,10"     # longitude out of range
gis clip in.tif out.tif --bbox "0,0,1,1" --bbox-crs EPSG:27700   # outside area of use

for _ in 1; do echo "exit: $?"; done

Every one should print a usage message naming the problem and exit 2. A run that reaches the command body and fails later means the check is in the wrong place.

Where the extent comes from varies, and each source needs a slightly different treatment before it reaches the parser.

Three sources of an extent A user types four numbers and needs a clear order convention. A config file supplies a list that must be validated the same way as the flag. Another dataset supplies bounds already in its own CRS, which must be transformed with densification rather than corner-mapped. typed by a user four comma-separated values needs the order spelled out in the metavar validate strictly from a config file a list of four numbers must pass the same checks as the flag does share the validator from another dataset bounds in that file's CRS transform with densified edges, never corners densify first

Sharing one validator across all three is what keeps a config-supplied box from bypassing the checks that a typed one gets.

Documenting the Convention Where People Will See It

Three places carry the order convention, and they have to agree or the one a user happens to read becomes the one they trust. The metavar appears in the usage line, which is what a user sees when they get the invocation wrong. The help string appears in --help, which is what they read before typing. And the error message appears when the check fires, which is the moment they most need the information.

Writing the order out in all three feels redundant when you are writing it and is not redundant when someone is debugging at speed. The version that reads best is the one that states the convention positively rather than describing the failure: Order: minx,miny,maxx,maxy rather than values must be in the correct order.

There is a fourth place worth considering for a tool used by a team: an example in the help epilogue showing a real invocation with real numbers. A user copying and adapting a working example makes far fewer ordering mistakes than one assembling four numbers from a description, and the epilogue costs two lines.

Accepting a Geometry Instead

A bounding box is the common case and not the only one. Some workflows need a real polygon — a catchment, an administrative boundary, a study area someone drew — and forcing them through a rectangle discards most of what they asked for.

The clean extension is a mutually exclusive pair of options: --bbox for the rectangle and --clip-geometry for a path to a vector file whose union becomes the mask. Both resolve to the same internal representation, so the command body does not branch:

def resolve_extent(bbox: BBox | None, clip_path: Path | None, crs: CRS):
    if bbox is not None and clip_path is not None:
        raise typer.BadParameter("--bbox and --clip-geometry are mutually exclusive")
    if clip_path is not None:
        import pyogrio
        from shapely import union_all
        frame = pyogrio.read_dataframe(clip_path)
        return union_all(frame.geometry.values)
    if bbox is not None:
        from shapely.geometry import box
        return box(*bbox.as_tuple())
    return None          # no spatial filter

Returning a geometry in both branches means the downstream code deals with one type. Returning a tuple in one branch and a polygon in the other means every consumer branches, and one of them eventually forgets.

The mutual-exclusion check belongs in the command body rather than in a parser, for the same reason the CRS domain check does: a parser sees one option and cannot know whether its sibling was also supplied. Raising BadParameter from the body still produces exit code 2 and a usage message, so the user experience is identical to a parser-level rejection.

One further detail matters for the geometry path: the file’s own CRS is authoritative, and the geometry must be transformed into the raster’s CRS before use. Reading the extent from a vector file and applying it without transforming is the same class of error as taking a bounding box in the wrong CRS, and it fails the same way — an empty result from a clip that looked correct.