Input & UX

Writing a Config Init and Edit Command for a Python CLI

Give users config init, edit, get and set commands: a commented starter file, their editor with validation afterwards, and comment-preserving edits via tomlkit.

Updated

A configuration file nobody can find is a configuration file nobody uses. Most CLIs document their settings in a README section that users skim once, then guess at the file’s location, its format and the exact spelling of each key. Tools such as git, gh, npm and poetry solved this with a small family of subcommands: config path tells you where the file lives, config init writes a commented starter file, config edit opens it in your editor, and config get/config set read and change single values from scripts. This guide builds that family for a Typer CLI with TOML configuration. The interesting parts are not the commands themselves but three details that make them pleasant: the starter file is commented, so it documents itself; edit validates the file after the editor closes and offers to reopen it; and set uses tomlkit so that changing one value keeps every comment and the user’s own formatting intact. It belongs to the configuration files and environment variables topic.

Prerequisites

  • A CLI that reads TOML configuration, as in reading TOML config with tomllib.
  • uv add typer tomlkit platformdirs (examples checked with Typer 0.27, tomlkit 0.15 and Python 3.13).

The command family

The config command family Five configuration subcommands of a command line tool, the question each one answers and who typically uses it. The config command family Command Answers Used by config path Where is the file? people, support config init What can I set? new users config edit Change it comfortably people config set Change one value scripts, setup guides config get What is the value? scripts Reading and merging configuration at runtime stays in the loader.

Each subcommand answers one question a user actually has. Where is it? — path. What can I put in it? — init, which writes every supported key with a comment explaining it. How do I change it comfortably? — edit. How do I change it from a script or a setup guide? — set. What is the current value? — get. Reading the file at runtime, merging it with flags and environment variables, stays where it was: in the loading code described in config precedence: flags, env, files and defaults. These commands only manage the file.

The recipe

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

import os
import shlex
import subprocess
import sys
import tomllib
from pathlib import Path
from typing import Annotated

import tomlkit
import typer
from platformdirs import user_config_path

TEMPLATE = """\
# mytool configuration — see `mytool config --help`.
# Command-line options and MYTOOL_* environment variables override these values.

[api]
# Base URL of the API.
url = "https://api.example.com"
# Seconds to wait for a response.
timeout = 30

[output]
# table, json or csv
format = "table"
"""

app = typer.Typer(help="Create, inspect and edit the configuration file.")


def config_path() -> Path:
    return Path(os.environ.get("MYTOOL_CONFIG") or user_config_path("mytool") / "config.toml")


def validate(text: str) -> list[str]:
    try:
        data = tomllib.loads(text)
    except tomllib.TOMLDecodeError as exc:
        return [f"not valid TOML: {exc}"]
    problems = []
    timeout = data.get("api", {}).get("timeout", 30)
    if not isinstance(timeout, (int, float)) or timeout <= 0:
        problems.append("api.timeout must be a positive number")
    if data.get("output", {}).get("format", "table") not in ("table", "json", "csv"):
        problems.append("output.format must be table, json or csv")
    return problems


def _write_atomic(path: Path, text: str) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp = path.with_name(f".{path.name}.tmp")
    tmp.write_text(text, encoding="utf-8")
    os.replace(tmp, path)


@app.command()
def init(force: Annotated[bool, typer.Option("--force", help="Overwrite an existing file.")] = False) -> None:
    """Write a commented starter configuration."""
    path = config_path()
    if path.exists() and not force:
        typer.echo(f"error: {path} already exists (use --force to overwrite)", err=True)
        raise typer.Exit(1)
    _write_atomic(path, TEMPLATE)
    typer.echo(f"wrote {path}", err=True)


@app.command()
def path() -> None:
    """Print the configuration file path."""
    typer.echo(config_path())


def _editor() -> list[str]:
    editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") or ("notepad" if os.name == "nt" else "vi")
    return shlex.split(editor, posix=os.name != "nt")


@app.command()
def edit() -> None:
    """Open the configuration in $VISUAL or $EDITOR and validate it afterwards."""
    path = config_path()
    if not path.exists():
        _write_atomic(path, TEMPLATE)
    while True:
        subprocess.run([*_editor(), str(path)], check=False)
        problems = validate(path.read_text(encoding="utf-8"))
        if not problems:
            typer.echo(f"saved {path}", err=True)
            return
        for problem in problems:
            typer.echo(f"error: {problem}", err=True)
        if not sys.stdin.isatty() or not typer.confirm("Edit again?", default=True, err=True):
            raise typer.Exit(1)


def _parse_value(raw: str):
    try:
        return tomllib.loads(f"v = {raw}")["v"]      # numbers, booleans, quoted strings, arrays
    except tomllib.TOMLDecodeError:
        return raw                                    # anything else is a bare string


@app.command("set")
def set_value(key: str, value: str) -> None:
    """Set a dotted KEY (for example api.timeout) to VALUE, keeping comments."""
    path = config_path()
    doc = tomlkit.parse(path.read_text(encoding="utf-8")) if path.exists() else tomlkit.document()
    *tables, leaf = key.split(".")
    node = doc
    for name in tables:
        node = node.setdefault(name, tomlkit.table())
    node[leaf] = _parse_value(value)
    text = tomlkit.dumps(doc)
    if problems := validate(text):
        typer.echo("error: " + "; ".join(problems), err=True)
        raise typer.Exit(1)
    _write_atomic(path, text)


@app.command("get")
def get_value(key: str) -> None:
    """Print the value of a dotted KEY."""
    node = tomllib.loads(config_path().read_text(encoding="utf-8"))
    for part in key.split("."):
        if not isinstance(node, dict) or part not in node:
            typer.echo(f"error: {key} is not set", err=True)
            raise typer.Exit(1)
        node = node[part]
    typer.echo(node)

Mount it on the main app with app.add_typer(config_cmd.app, name="config"), and the five commands appear as mytool config init, mytool config edit and so on.

A starter file that documents itself

init writes a template string rather than serialising a dictionary. That is deliberate: a dumped dictionary has no comments, while the template explains each key, names the allowed values and points at the override order. It is the first thing a user reads about your settings, so treat it as documentation — keep it in sync with the settings model, and test that it parses and passes validation (the first test below does exactly that, implicitly, by setting values afterwards).

init refuses to overwrite an existing file without --force. Overwriting someone’s carefully tuned configuration because they ran a setup command twice is the kind of mistake users remember. Both init and set write through _write_atomic: the new content goes to a temporary file in the same directory, then os.replace swaps it in, so an interrupted write never leaves a half-written file behind. The pattern is explained in writing files atomically in Python CLIs.

Opening the user’s editor

edit follows the Unix convention: $VISUAL first, then $EDITOR, then a platform default (vi on Unix, notepad on Windows). The variable may contain arguments — code --wait, subl -w — so it is split with shlex.split rather than used as a single program name. The command runs the editor with the file path, waits for it to exit, and then validates the result. If the file is broken, it prints what is wrong and asks whether to edit again, which is exactly how git commit, crontab -e and visudo behave. In a non-interactive session it does not ask; it exits with status 1, so automation fails loudly instead of hanging on a prompt.

Graphical editors need their “wait” flag. code returns immediately unless given --wait, which means the validation would run on the unchanged file. Document the common values (code --wait, subl -w, gedit -s) in the help text, or check whether the file’s modification time changed and warn when it did not.

Edit, then validate The config edit command opens the user’s editor, validates the saved file, and either finishes or offers to edit again. Edit, then validate Resolve editor $VISUAL, $EDITOR, vi Editor runs command waits Validate TOML + rules Saved or retry prompt only on a TTY shlex.split exit problems? Automation gets exit code 1 instead of a prompt that never gets an answer.

Changing one value without losing comments

set is where most implementations go wrong. Loading the file with tomllib, changing a dictionary and writing it back with a TOML serialiser throws away every comment, blank line and key ordering the user chose. tomlkit parses TOML into a document object that remembers all of that: assigning doc["api"]["timeout"] = 60 changes exactly one value, and tomlkit.dumps(doc) reproduces the rest of the file byte for byte.

# before                                   # after: mytool config set api.timeout 60
[api]                                      [api]
# Seconds to wait for a response.          # Seconds to wait for a response.
timeout = 30                               timeout = 60

The value itself is parsed as a TOML expression, so 60 becomes an integer, true a boolean, "60" a string and ["a", "b"] an array; anything that is not valid TOML — json, https://x.example — falls back to a bare string. Dotted keys create missing tables on the way, so mytool config set cache.dir ~/.cache/mytool works on an empty file. Before writing, the new text goes through the same validate function as edit, so a script cannot store a value that the loader would reject later.

get reads with the standard library’s tomllib, since it never writes, and walks the dotted key. It prints only the value on stdout, which makes timeout=$(mytool config get api.timeout) work in shell scripts; errors go to stderr with a non-zero exit code.

Writing back one changed value Comparison of what survives when a configuration file is rewritten with a plain TOML serialiser versus tomlkit. Writing back one changed value After set tomllib + writer tomlkit Changed value yes yes Comments lost kept Key order and blank lines normalised kept Quoting and number style normalised kept Users annotate their configuration; a tool should never erase the notes.

UX considerations

  • Print the path, always. init, edit and set mention the file they touched, on stderr. Users with several profiles or a MYTOOL_CONFIG override need to see which file changed.
  • Respect overrides. config_path() honours the same MYTOOL_CONFIG variable the loader uses; otherwise edit opens one file while the CLI reads another.
  • Validate in one place. edit, set and the runtime loader should share the same validation function — or better, the same settings model, as in typed settings with pydantic-settings.
  • Never echo secrets. If the file can hold tokens, get should refuse those keys or mask them, and the template should point to a keyring instead, as in storing tokens with keyring.
  • Show effective values separately. config get reports the file; a config show that merges flags, environment and file — and says where each value came from — answers the more common question “why is it using that?”.

Testing the behaviour

Each test points MYTOOL_CONFIG at a temporary file, so the user’s real configuration is never touched. The editor is replaced by a tiny Python script set as $VISUAL, which makes the full edit path — launching, waiting, validating — run in the test without any interactive program:

# tests/test_config_cmd.py
import sys

import pytest
from typer.testing import CliRunner

from mytool.config_cmd import app

runner = CliRunner()


@pytest.fixture
def cfg(tmp_path, monkeypatch):
    path = tmp_path / "config.toml"
    monkeypatch.setenv("MYTOOL_CONFIG", str(path))
    return path


def test_init_writes_commented_template_once(cfg):
    assert runner.invoke(app, ["init"]).exit_code == 0
    assert "# Seconds to wait for a response." in cfg.read_text()
    assert runner.invoke(app, ["init"]).exit_code == 1


def test_set_preserves_comments_and_types(cfg):
    runner.invoke(app, ["init"])
    assert runner.invoke(app, ["set", "api.timeout", "60"]).exit_code == 0
    text = cfg.read_text()
    assert "timeout = 60" in text and "# Seconds to wait" in text
    assert runner.invoke(app, ["get", "api.timeout"]).stdout == "60\n"


def test_set_rejects_invalid_values(cfg):
    runner.invoke(app, ["init"])
    result = runner.invoke(app, ["set", "output.format", "xml"])
    assert result.exit_code == 1 and "output.format must be" in result.stderr
    assert 'format = "table"' in cfg.read_text()


def test_edit_runs_editor_and_validates(cfg, monkeypatch, tmp_path):
    runner.invoke(app, ["init"])
    fake = tmp_path / "fake_editor.py"
    fake.write_text("import sys, pathlib\np = pathlib.Path(sys.argv[1])\n"
                    "p.write_text(p.read_text().replace('timeout = 30', 'timeout = 5'))\n")
    monkeypatch.setenv("VISUAL", f"{sys.executable} {fake}")
    result = runner.invoke(app, ["edit"])
    assert result.exit_code == 0 and "timeout = 5" in cfg.read_text()


def test_edit_reports_broken_file(cfg, monkeypatch, tmp_path):
    runner.invoke(app, ["init"])
    fake = tmp_path / "break.py"
    fake.write_text("import sys, pathlib\npathlib.Path(sys.argv[1]).write_text('[api\\n')\n")
    monkeypatch.setenv("VISUAL", f"{sys.executable} {fake}")
    result = runner.invoke(app, ["edit"])
    assert result.exit_code == 1 and "not valid TOML" in result.stderr

The fake editor trick generalises: any command that launches an external program configured through an environment variable can be tested by pointing that variable at sys.executable plus a short script. Typer’s CliRunner has no TTY on stdin, so the broken-file test exercises the non-interactive branch and checks that it exits instead of prompting.

Conclusion

A config command family turns a hidden file into a discoverable feature. Write a commented template with init and refuse to overwrite without --force; open $VISUAL or $EDITOR with edit, split with shlex, and validate afterwards with an offer to retry; change single values with set through tomlkit so comments and formatting survive; print plain values from get for scripts; write atomically; share validation with the runtime loader; and test the editor path with a fake editor script.

Frequently asked questions

Why not let set rewrite the file with a regular TOML writer?

Because it deletes the user’s comments and reorders keys. Users annotate configuration files (“raised for the slow VPN”); a tool that silently removes those notes loses trust quickly. tomlkit exists precisely to avoid that.

What if my configuration is YAML, not TOML?

The same command family works. ruamel.yaml in round-trip mode plays the role of tomlkit, preserving comments and ordering, while the safe-loading rules in loading YAML configs safely in CLI apps still apply to reading.

Should config edit create the file if it does not exist?

Yes — starting from the commented template, as above, is far more helpful than an empty buffer. Print that you created it, so users are not surprised to find a new file.

Can I use click.edit() instead of launching the editor myself?

click.edit(filename=...) does the same job and handles the $VISUAL/$EDITOR lookup. Typer bundles its own copy of Click, so call it through typer’s Click if you need it; the explicit subprocess.run above is easier to test and to explain. Launching editors in general is covered in launching the user’s editor from a CLI.

How should set handle unknown keys?

Reject them, with a suggestion when one is close — api.timout should produce “unknown key, did you mean api.timeout?”, as in did you mean suggestions for mistyped input. A typo silently stored in the file is a setting that never takes effect.