Input & UX

Documenting Environment Variables in CLI Help

Make a Python CLI’s environment variables discoverable: show_envvar in Click and Typer, auto_envvar_prefix naming, an Environment section in help, an env command, and a test that none are undocumented.

Updated

Environment variables are half of a CLI's interface and usually the undocumented half. Options appear in --help automatically; environment variables appear wherever someone remembered to mention them — a README section that is two releases out of date, a comment in the code, a Slack message. Yet they are exactly what CI pipelines, containers and cron jobs use to configure tools, because nobody wants a token on a command line. Users then discover MYTOOL_REGION by reading the source, or never discover MYTOOL_DEBUG at all. This guide makes environment variables first-class in help: showing the variable next to each option it can set, naming them predictably, documenting the variables that are not tied to options in an "Environment" section, adding an env command that shows what is currently set, and testing that no variable goes undocumented. It belongs to the help output and documentation topic.

Prerequisites

Two kinds of environment variable

Two kinds of environment variable A comparison of option-backed environment variables with free-standing environment variables in a command line tool. Two kinds of environment variable Kind Example Documented by Option-backed MYTOOL_REGION → --region the framework (show_envvar) Free-standing MYTOOL_DEBUG, NO_COLOR you — a registry The framework can only document what it parses.

Option-backed variables supply a value for a specific option — MYTOOL_REGION for --region. The framework reads them, validates them like command-line values, and can display them in help. Free-standing variables affect behaviour without a matching option: MYTOOL_CONFIG (where the config file lives), MYTOOL_DEBUG, MYTOOL_NO_UPDATE_CHECK, and the conventions your tool honours, such as NO_COLOR and DO_NOT_TRACK. The framework knows nothing about these, so you must document them yourself.

The recipe: option-backed variables

Click: show_envvar and auto_envvar_prefix

# src/mytool/cli.py
import click


@click.group(context_settings={"auto_envvar_prefix": "MYTOOL", "show_default": True})
def cli() -> None:
    """Deploy services."""


@cli.command()
@click.option("--region", default="eu-west-1", show_envvar=True, help="Region.")
@click.option("--token", envvar="MYTOOL_TOKEN", show_envvar=True, help="API token.")
def deploy(region: str, token: str | None) -> None:
    """Deploy the current build."""
Options:
  --region TEXT  Region.  [env var: MYTOOL_DEPLOY_REGION; default: eu-west-1]
  --token TEXT   API token.  [env var: MYTOOL_TOKEN]

auto_envvar_prefix gives every option an environment variable named PREFIX_COMMAND_OPTION automatically — MYTOOL_DEPLOY_REGION here — and show_envvar=True prints it in help. Explicit envvar= names override the automatic scheme for variables that should be shared across commands, such as a token. There is no context setting that turns show_envvar on for every option at once, so set it per option — or define shared option decorators with it already enabled, as in sharing common options across commands.

Typer: shown by default

@app.command()
def deploy(
    region: Annotated[str, typer.Option(envvar="MYTOOL_REGION", help="Region.")] = "eu-west-1",
    token: Annotated[str, typer.Option(envvar="MYTOOL_TOKEN", help="API token.")] = "",
) -> None:
    ...

Typer displays [env var: MYTOOL_REGION] for any option with envvar= unless you pass show_envvar=False. Showing the name of a secret's variable is fine and helpful — it is the value that must never appear, and help never shows values from the environment.

Variables in help and in use Terminal session showing environment variable names in option help, the env command listing current values with a secret hidden, and a configuration source report. Variables in help and in use bash $ mytool deploy --help | grep region --region TEXT Region. [env var: MYTOOL_DEPLOY_REGION; default: eu-west-1] $ mytool env MYTOOL_CONFIG (not set) MYTOOL_TOKEN (set, hidden) MYTOOL_DEBUG 1 Names are always safe to show; secret values never are.

Naming

Pick one scheme and keep it: an upper-case tool prefix, then a name that matches the option (MYTOOL_REGION for --region). Click's automatic PREFIX_COMMAND_OPTION scheme is predictable but produces long names for nested commands; explicit names are shorter but must be maintained. Whichever you choose, never reuse a generic name like REGION or TOKEN — other tools in the same environment will collide with it.

The recipe: free-standing variables

For variables the framework does not know about, keep a single registry in code and generate documentation from it:

# src/mytool/envvars.py
from __future__ import annotations

import os
from dataclasses import dataclass


@dataclass(frozen=True)
class EnvVar:
    name: str
    description: str
    secret: bool = False


ENV_VARS = [
    EnvVar("MYTOOL_CONFIG", "Path to the configuration file."),
    EnvVar("MYTOOL_TOKEN", "API token (prefer `mytool login`).", secret=True),
    EnvVar("MYTOOL_DEBUG", "Set to 1 for full tracebacks and debug logs."),
    EnvVar("MYTOOL_NO_UPDATE_CHECK", "Set to 1 to disable update notices."),
    EnvVar("NO_COLOR", "Disable coloured output (see no-color.org)."),
]


def environment_epilog() -> str:
    width = max(len(v.name) for v in ENV_VARS)
    lines = ["Environment:"] + [f"  {v.name:<{width}}  {v.description}" for v in ENV_VARS]
    return "\n".join(lines)


def current_values() -> list[tuple[str, str]]:
    rows = []
    for v in ENV_VARS:
        value = os.environ.get(v.name)
        if value is None:
            shown = "(not set)"
        elif v.secret:
            shown = "(set, hidden)"
        else:
            shown = value
        rows.append((v.name, shown))
    return rows

Use the epilog on the root command (with a formatter that keeps line breaks — Click's \b marker or Typer's Rich epilog), and add a small command that shows the current state:

@cli.command("env")
def env_cmd() -> None:
    """Show the environment variables mytool reads and their current values."""
    for name, shown in current_values():
        click.echo(f"{name:<24} {shown}")

mytool env answers the support question "what is actually configured on that CI runner?" in one paste, without ever printing a secret, as recommended in redacting secrets from CLI output and logs.

One registry, three outputs A single registry of environment variables generates the help epilog, the env command and a test that checks the source code for undocumented variables. One registry, three outputs ENV_VARS name, description, secret Help epilog Environment: section mytool env current values, redacted Test source scan finds gaps renders reads checks Adding a variable without documenting it fails the test.

Showing where each value came from

Documentation tells users which variables exist; the hardest debugging question is which one won. Click records the source of every parameter value, so a command can report it — the most useful addition to a --verbose mode or a dedicated config show command:

@cli.command("show-config")
@click.option("--region", default="eu-west-1", show_envvar=True)
@click.option("--retries", default=3, type=int, show_envvar=True)
@click.pass_context
def show_config(ctx: click.Context, region: str, retries: int) -> None:
    """Print each setting with the place its value came from."""
    for name, value in ctx.params.items():
        source = ctx.get_parameter_source(name)
        click.echo(f"{name}={value!r} ({source.name.lower()})")
$ MYTOOL_SHOW_CONFIG_REGION=us-east-1 mytool show-config --retries 5
region='us-east-1' (environment)
retries=5 (commandline)

get_parameter_source distinguishes the command line, environment variables, default_map (configuration files loaded as in Click option callbacks and eager options) and declared defaults. With Typer, the same method exists on the context, but compare source.name rather than importing Click's enum, because Typer uses its own vendored copy of Click.

UX considerations

  • Document precedence once, briefly. "Command-line options override environment variables, which override the config file" in the root help saves many questions.
  • Validate environment values like flags. Option-backed variables are validated by the framework; for free-standing ones, a bad value should produce a clear error naming the variable, not a mysterious failure later.
  • Treat names as API. Renaming a variable breaks every pipeline that sets it; support the old name with a deprecation warning for a release, as with flags in versioning and deprecating CLI flags.
  • Keep the list short. Every variable is something users must discover and you must support; prefer options with envvar= over free-standing variables when a value maps to an option anyway.

Testing the behaviour

Two tests keep the documentation honest. One checks that help shows the variables; the other scans the source for os.environ reads of MYTOOL_* names and fails if any are missing from the registry:

# tests/test_envvars.py
import re
from pathlib import Path

from click.testing import CliRunner

from mytool.cli import cli
from mytool.envvars import ENV_VARS, current_values

SRC = Path(__file__).resolve().parents[1] / "src" / "mytool"
READ = re.compile(r"""os\.environ(?:\.get)?[\[(]\s*["'](MYTOOL_[A-Z0-9_]+)["']""")


def test_option_envvars_are_shown_in_help():
    output = CliRunner().invoke(cli, ["deploy", "--help"]).output
    assert "env var: MYTOOL_DEPLOY_REGION" in output


def test_every_free_standing_variable_is_documented():
    documented = {v.name for v in ENV_VARS}
    used = {m for f in SRC.rglob("*.py") for m in READ.findall(f.read_text(encoding="utf-8"))}
    assert used <= documented, f"undocumented: {sorted(used - documented)}"


def test_secrets_are_never_shown(monkeypatch):
    monkeypatch.setenv("MYTOOL_TOKEN", "s3cr3t")
    assert ("MYTOOL_TOKEN", "(set, hidden)") in current_values()

The source scan is crude but effective: it catches the common case of someone adding os.environ.get("MYTOOL_FOO") in a new module and forgetting to tell anyone.

Conclusion

Environment variables deserve the same visibility as options. Show option-backed variables in help with show_envvar (Click) or the default display (Typer), name them predictably under a tool prefix, keep free-standing variables in one registry that generates an "Environment" help section and an env command, never display secret values, and test that every variable the code reads is documented. CI pipelines and containers will thank you.

Frequently asked questions

Should every option have an environment variable?

No. Options that configure the environment — credentials, endpoints, regions, output preferences — benefit; options that describe a single invocation, such as which file to process, rarely do. auto_envvar_prefix gives every option one for free, which is convenient but makes the interface larger than you may want to support.

How do boolean environment variables work?

Click and Typer accept common truthy and falsy strings (1, true, yes, 0, false, no) for boolean options. For free-standing variables, parse them with the same rules in one helper, so MYTOOL_DEBUG=yes and MYTOOL_DEBUG=1 behave the same.

Can I generate a man page section from the registry?

Yes — the same list can feed an ENVIRONMENT section in generated man pages or Markdown docs, as described in generating man pages and docs from a CLI.

What about .env files?

Loading a .env file is a separate decision with its own trade-offs; if your CLI supports it, list that in the Environment section too, and make the file's location and precedence explicit, as in reading secrets from env and files.

Do environment variables work on Windows the same way?

Yes, though users set them differently ($env:MYTOOL_REGION = "us-east-1" in PowerShell, set MYTOOL_REGION=us-east-1 in cmd), and names are case-insensitive there. Show the PowerShell form next to the POSIX one in your docs, since copying export ... into PowerShell fails.