Article

Layering TOML and Env Config for Raster Pipelines

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 — tomllib is in the standard library from 3.11; on 3.10 install tomli and import it as tomllib
  • No third-party parser needed for reading; tomllib is 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.

Precedence chain: defaults, TOML, env, flags Four stacked layers from lowest to highest priority. Defaults set target_epsg to 4326, the TOML file overrides it to 3857, an environment variable is silent, and a CLI flag sets 32633 which becomes the resolved value. lowest priority highest priority 1. Defaults (dataclass) target_epsg = 4326 baseline 2. TOML [tool.mytool] target_epsg = 3857 overwrites default 3. Env MYTOOL_TARGET_EPSG (unset — layer is silent) no change 4. CLI flag --target-epsg target_epsg = 32633 → resolved wins

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

  1. @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 via dataclasses.replace, which makes each overwrite explicit and keeps the merge free of hidden mutation. The field types (int, Path, bool) drive coercion later.

  2. _coerce handles the string-to-type gap — TOML gives you real types, but os.environ values are always strings. This function maps each raw string to its field’s declared type. The bool branch is the load-bearing one: bool("0") is True in Python, so a naive cast would treat MYTOOL_OVERWRITE=0 as enabling overwrite.

  3. _load_toml opens in binary modetomllib.load requires a file opened with "rb"; passing a text handle raises TypeError. 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.

  4. _load_env strips the prefix and lowercasesMYTOOL_MAX_WORKERS becomes max_workers, aligning with the dataclass field name. Namespacing with MYTOOL_ avoids collisions with unrelated environment variables such as GDAL_NUM_THREADS, a point covered in depth by Environment Variable Sync.

  5. CLI flags default to None — The merge applies a flag only when its value is not None. This is what makes an omitted --max-workers leave 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 plain argparse mirrors the same discipline.

  6. 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.