Skip to content

← Config, Logs and Untrusted Input step 5 of 16

Medium End-to-End

Merge Settings from a File, the Environment and the Command Line

Your command-line tool takes its settings from three places: a config file, environment variables and command-line options. The command line beats the environment, which beats the file, which beats the defaults. Write a resolver that merges them and records where each final value came from, so that "why is the port 9200?" has an answer.

solve({"port": 9000}, {"APP_PORT": "9100", "PATH": "/bin"}, ["--port=9200", "--verbose"])
# {"ok": True, "host": "localhost", "port": 9200, "workers": 4, "verbose": True,
#  "sources": {"host": "default", "port": "cli", "workers": "default", "verbose": "cli"}}

What to write

Fill in resolve_config(file_table, env, argv) and return a Resolved. Keep the supplied constants, dataclasses, ConfigError and solve as they are. Read only the three inputs, not a file, os.environ or sys.argv, and do not change them.

Field Type Default
host str "localhost"
port int, not bool 8080
workers int, not bool 4
verbose bool False

Apply the sources in order

defaults < file < environment < command line

Start from the defaults. Apply the file entries in the order they appear, then the environment entries in order, then the command-line tokens in order. Each value replaces the field's current value. Set sources[field] to "default", "file", "env" or "cli" for the last source that set the field, even when it set the same value as before.

Check each value as soon as you reach it, and stop at the first error. A bad value in the file is an error even when the command line would replace it.

Check file values, convert text values

The file arrives already parsed, as from tomllib.load, so its values have types. Its keys must match the field names exactly. Check that each value has its field's type, without converting it. A file port of "9000" is a type error. Reject True and False for an integer field, because bool is a subclass of int.

Environment and command-line values are always text. Convert them by field type:

  • For a str field, keep the text exactly, including spaces and the empty string.
  • For an int field, use int(raw). It accepts a sign, spaces around the number and underscores between digits, as in "1_000". It rejects "3.0" and "0x10". Turn its ValueError into a ConfigError with kind "type".
  • For a bool field, use raw.strip().lower(). Accept 1, true, yes and on as True, and 0, false, no and off as False. Any other text is a "type" error.

Check types only. An empty host and a negative port are both allowed.

Environment variables

Only variables whose names start with exactly APP_ belong to this tool. Ignore the rest, such as PATH. Lowercase the part after the prefix to get the field name, so APP_PORT and APP_port both set port. If both appear, the later one wins. A prefixed name that matches no field, such as APP_TIEMOUT, is an "unknown" error.

Command-line options

  • --key=value sets a field. Split at the first =, so --host=a=b sets host to "a=b". In the key, change dashes to underscores, but keep its case.
  • --verbose sets verbose to True, and --no-verbose sets it to False. These are the only options without an =.
  • Options apply in order, so a repeated option takes its last value.
  • There is no --port 9000 form with a separate value, no positional argument, no --help and no -- end marker.

Errors

Raise ConfigError(layer, key, kind). layer names the source of the bad value: "file", "env" or "cli". key is the original file key or variable name, or the whole command-line token. Check in this order, and use the first kind that applies:

Check kind
The token does not start with -- "malformed"
The field name is not in FIELDS "unknown"
An option for a non-boolean field has no = "malformed"
The file value has the wrong type, or the text does not convert "type"

So --port is malformed, --missing is unknown, and --port=x is a type error. --no-verbose=false contains =, so its key is no_verbose, which is unknown. A bare -- has an empty key, which is unknown too.

What the driver reports

Return Resolved(config=Config(...), sources=...), with a new sources dictionary on every call. solve turns a success into the report in the first example, and a ConfigError into a report like this one:

solve({"port": "9000"}, {}, ["--port=9200"])
# {"ok": False, "layer": "file", "key": "port", "kind": "type"}

Your code must also pass mypy --strict. The values you collect have the type object. Check each one with isinstance before you pass it to Config, so mypy knows its type.