To layer configuration for a raster CLI, resolve settings in a fixed order: built-in defaults, then a [tool.mytool] TOML table read with tomllib, then MYTOOL_-prefixed environment variables, then explicit command-line flags. Each layer overwrites only the keys it actually supplies, and the last writer wins per field. This page is part of the Configuration File Management for GIS CLI Tools guide within the broader CLI Architecture & Design Patterns reference.
The hard part is not reading a file. It is making precedence deterministic and testable so a target_epsg set in a deployment env var never gets silently reset by a stale default, and so a single --max-workers flag on the command line always wins.
Prerequisites
- Python 3.11 or later —
tomllibis in the standard library from 3.11; on 3.10 installtomliand import it astomllib - No third-party parser needed for reading;
tomllibis read-only by design - GDAL / rasterio only matters at run time — the loader here is pure standard library, which keeps it trivial to unit-test without a raster fixture
For the wider file-versus-flag trade-offs, start with the Configuration File Management for GIS CLI Tools overview. This page focuses narrowly on the TOML-plus-env precedence chain rather than file format choice.
The Precedence Chain
Configuration resolution is a fold over four layers. Each layer is a partial mapping — it may set some fields and stay silent on others. You start from a fully-populated defaults object and apply each subsequent layer as an overwrite of only its present keys. The diagram shows how a single field, target_epsg, threads through the four layers and where the winning value comes from.
Complete Working Implementation
The loader below is self-contained standard library. It reads a [tool.mytool] table from a TOML file, overlays MYTOOL_-prefixed environment variables with type coercion, then applies only the CLI overrides the caller passed. It also records where each field’s final value came from:
#!/usr/bin/env python3
"""
Layered configuration for a raster pipeline CLI.
Order (lowest to highest): defaults -> TOML -> env -> CLI flags.
Requires Python 3.11+ for tomllib.
"""
from __future__ import annotations
import os
import tomllib
from dataclasses import dataclass, fields, replace
from pathlib import Path
from typing import Any
ENV_PREFIX = "MYTOOL_"
TOML_TABLE = ("tool", "mytool")
@dataclass(frozen=True)
class RasterConfig:
target_epsg: int = 4326 # output CRS as a bare EPSG code
max_workers: int = 4 # parallel warp workers
chunk_size: int = 512 # tile edge in pixels for windowed reads
output_dir: Path = Path("./out") # where reprojected rasters land
overwrite: bool = False # replace existing outputs
def _coerce(field_type: Any, raw: str) -> Any:
"""Coerce a raw string (from env) to the dataclass field's type.
Environment values are ALWAYS strings; TOML values are already typed.
bool needs special handling because bool("0") is True in Python.
"""
if field_type is bool:
return raw.strip().lower() in {"1", "true", "yes", "on"}
if field_type is int:
return int(raw)
if field_type is Path:
return Path(raw)
return raw # str fields pass through unchanged
def _load_toml(path: Path) -> dict[str, Any]:
"""Return the [tool.mytool] table, or {} if the file/table is absent."""
if not path.is_file():
return {}
with path.open("rb") as fh: # tomllib requires binary mode
doc = tomllib.load(fh)
table: Any = doc
for key in TOML_TABLE:
table = table.get(key, {}) if isinstance(table, dict) else {}
return table if isinstance(table, dict) else {}
def _load_env() -> dict[str, str]:
"""Collect MYTOOL_-prefixed vars, lowercased to field names."""
out: dict[str, str] = {}
for key, value in os.environ.items():
if key.startswith(ENV_PREFIX):
out[key[len(ENV_PREFIX):].lower()] = value
return out
def load_config(
toml_path: Path = Path("pyproject.toml"),
cli_overrides: dict[str, Any] | None = None,
) -> tuple[RasterConfig, dict[str, str]]:
"""Resolve config across all four layers.
Returns the final RasterConfig plus a provenance map recording which
layer supplied each field's winning value.
"""
field_types = {f.name: f.type for f in fields(RasterConfig)}
valid = set(field_types)
config = RasterConfig() # layer 1: defaults
provenance = {name: "default" for name in valid}
# layer 2: TOML — values are already correctly typed by tomllib
for name, value in _load_toml(toml_path).items():
if name in valid:
coerced = Path(value) if field_types[name] is Path else value
config = replace(config, **{name: coerced})
provenance[name] = "toml"
# layer 3: env — every value is a string, so coerce per field type
for name, raw in _load_env().items():
if name in valid:
config = replace(config, **{name: _coerce(field_types[name], raw)})
provenance[name] = "env"
# layer 4: CLI flags — merge ONLY keys the user actually set (not None)
for name, value in (cli_overrides or {}).items():
if name in valid and value is not None:
config = replace(config, **{name: value})
provenance[name] = "flag"
return config, provenance
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Raster pipeline runner")
parser.add_argument("--config", type=Path, default=Path("pyproject.toml"))
# CLI flags DEFAULT TO None so an unset flag never clobbers lower layers.
parser.add_argument("--target-epsg", type=int, default=None)
parser.add_argument("--max-workers", type=int, default=None)
parser.add_argument("--chunk-size", type=int, default=None)
parser.add_argument("--output-dir", type=Path, default=None)
parser.add_argument("--overwrite", action="store_true", default=None)
args = parser.parse_args()
overrides = {
"target_epsg": args.target_epsg,
"max_workers": args.max_workers,
"chunk_size": args.chunk_size,
"output_dir": args.output_dir,
"overwrite": args.overwrite,
}
cfg, prov = load_config(args.config, overrides)
print("Resolved raster pipeline configuration:")
for f in fields(cfg):
print(f" {f.name:<12} = {getattr(cfg, f.name)!r:<20} (from {prov[f.name]})")
A matching pyproject.toml fragment the loader reads:
[tool.mytool]
target_epsg = 3857
max_workers = 8
output_dir = "./reprojected"
Step Annotations
-
@dataclass(frozen=True)with typed defaults — The dataclass is both the schema and the defaults layer. Freezing it forces every layer to produce a new config viadataclasses.replace, which makes each overwrite explicit and keeps the merge free of hidden mutation. The field types (int,Path,bool) drive coercion later. -
_coercehandles the string-to-type gap — TOML gives you real types, butos.environvalues are always strings. This function maps each raw string to its field’s declared type. Theboolbranch is the load-bearing one:bool("0")isTruein Python, so a naive cast would treatMYTOOL_OVERWRITE=0as enabling overwrite. -
_load_tomlopens in binary mode —tomllib.loadrequires a file opened with"rb"; passing a text handle raisesTypeError. Walking the("tool", "mytool")path with.get(..., {})means a missing file or missing table yields an empty dict rather than an exception, so the layer simply contributes nothing. -
_load_envstrips the prefix and lowercases —MYTOOL_MAX_WORKERSbecomesmax_workers, aligning with the dataclass field name. Namespacing withMYTOOL_avoids collisions with unrelated environment variables such asGDAL_NUM_THREADS, a point covered in depth by Environment Variable Sync. -
CLI flags default to
None— The merge applies a flag only when its value is notNone. This is what makes an omitted--max-workersleave the TOML value intact instead of resetting it. Coupling the parser with a typed config layer is exactly the boundary Argument Parsing with Typer is built to enforce; here plainargparsemirrors the same discipline. -
The provenance map — Each layer updates
provenance[name]as it overwrites a field. Printing field, value, and source together turns “why is my output CRS wrong?” into a one-line answer.
Named Gotcha: Environment Values Are Always Strings
The single most common failure is trusting that an environment variable arrives with the type you wrote in TOML. It does not. Set MYTOOL_MAX_WORKERS=8 and, without coercion, config.max_workers is the string "8" — so range(config.max_workers) raises TypeError and config.max_workers > 4 raises on the comparison. The subtler trap is boolean flags: MYTOOL_OVERWRITE=0 is meant to disable overwrite, but bool("0") is True, so the pipeline clobbers existing rasters.
The fix is the _coerce function above. It routes int fields through int(), Path fields through Path(), and treats only the explicit truthy tokens 1, true, yes, and on as True for booleans. TOML values skip coercion because tomllib already types them — an unquoted target_epsg = 3857 is an int, while a quoted "3857" would stay a str, which is itself a reason to keep EPSG codes unquoted in your TOML.
Verification
Run the module with a layered setup and confirm each field resolves from the layer you expect:
# TOML sets max_workers=8; env overrides target_epsg; a flag overrides chunk_size.
export MYTOOL_TARGET_EPSG=32633
python config_loader.py --chunk-size 1024
# Expected output:
# target_epsg = 32633 (from env)
# max_workers = 8 (from toml)
# chunk_size = 1024 (from flag)
# output_dir = PosixPath('reprojected') (from toml)
# overwrite = False (from default)
For an automated regression check that precedence never silently changes, assert on the resolved values directly:
import os
from pathlib import Path
from config_loader import load_config
os.environ["MYTOOL_TARGET_EPSG"] = "32633"
cfg, prov = load_config(
toml_path=Path("pyproject.toml"),
cli_overrides={"chunk_size": 1024, "max_workers": None},
)
assert cfg.target_epsg == 32633 and prov["target_epsg"] == "env"
assert cfg.chunk_size == 1024 and prov["chunk_size"] == "flag"
assert prov["max_workers"] == "toml" # unset flag did NOT overwrite
assert isinstance(cfg.max_workers, int) # coercion held
print("precedence chain verified")
The type assertion is the important one: it catches the string-coercion regression before it reaches GDAL, where a mistyped max_workers surfaces as an opaque worker-pool error rather than a config bug.
FAQ
Why does tomllib read integers correctly but environment variables do not?
tomllib parses TOML types natively, so an unquoted 4 becomes a Python int and a quoted "4" stays a str. Environment variables are always strings, so MYTOOL_MAX_WORKERS=4 arrives as the string '4'. You must coerce env values to each dataclass field’s declared type before merging, or comparisons and arithmetic downstream will misbehave.
How do I stop unset CLI flags from overwriting my TOML values?
Never default your CLI flags to the config defaults. Default them to None and merge only the keys whose value is not None. That way a flag the user omitted contributes nothing to the final layer and the TOML or env value survives as the resolved setting.
Should the TOML file live in pyproject.toml or a separate file?
Both work with the same loader. Reusing pyproject.toml under a [tool.mytool] table keeps project-scoped defaults with the code, while a standalone raster.toml suits per-run or per-dataset overrides. Resolve the standalone file after pyproject.toml so it wins, keeping the defaults-to-file-to-env-to-flag order intact.
How can I tell which layer supplied each final value?
Track provenance while you merge. Record the source name alongside each field as later layers overwrite earlier ones, then print field, value, and source together. This turns a silent CRS mismatch into a one-line answer about whether the value came from the default, the TOML file, an env var, or a flag.
Related
- Configuration File Management for GIS CLI Tools — parent guide covering file formats, precedence, and reload strategies for geospatial CLI tools
- Managing YAML Configs for Geospatial CLI Workflows — the YAML counterpart when your pipeline needs anchors, comments, or nested profiles instead of TOML