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
- Python 3.10 or later
pip install "typer>=0.12" "pyproj>=3.6"- A command that already validates its CRS, per Validating EPSG Codes as Typer CLI Options
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.
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.
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
parser=rather than acallback=. A parser returns the converted value, so the command body receives aBBoxand not a string it has to re-parse — the mapping described in Migrating a Click Geospatial CLI to Typer.metavarspells out the order. It appears in the usage line, which is where someone actually looks before typing the option.- A separate
--bbox-crswith 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. - 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. check_domainis 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.
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.
Related
- Argument Parsing with Typer — where option parsers sit in the call order.
- Validating EPSG Codes as Typer CLI Options — the CRS half of the same boundary check.