Skip to content

Repository files navigation

confargs

⚠️ Early development. APIs may change.

confargs is a small, declarative CLI argument parser for Python 3.10+ that merges configuration from three sources into one result:

  1. Command line arguments (--log out.html, -l NONE)
  2. Environment variables (per-option or auto-generated)
  3. TOML config files (discovered by walking up from the current directory, pyproject.toml-style)

You describe options as methods on a class. Each method receives the raw value from whichever source supplied it, performs any parsing/validation you like, and returns the final value. confargs handles discovery, precedence and basic type coercion; your code owns the domain logic.

import confargs
from confargs import ArgConfig


class MyArgs(ArgConfig):
    """My CLI tool.

    Longer description shown in --help.
    """

    tool_name = "mytool"

    # Declarative option: no method needed when there's nothing to parse.
    title = confargs.option(name="title", default="report", help="Report title.")

    @confargs.option
    def log(self, value: str | None = "log.html") -> str | None:
        """HTML log file. Disable with the special value 'NONE'."""
        if value == "NONE":
            return None
        return value

    @confargs.option(name="console", short="c")
    def console(self, value: str = "verbose") -> str:
        choices = ["verbose", "dotted", "quiet", "none"]
        if value not in choices:
            raise confargs.OptionValueError(f"console must be one of {choices}")
        return value


config = confargs.ConfigurationProcessor(MyArgs).process()
print(config.title, config.log, config.console)

Declaring options

Options come in two flavours:

  • Method-based (@confargs.option): the decorated method receives the raw value and returns the parsed/validated result. Use this whenever you need to transform or validate the value.
  • Declarative (attr = confargs.option(name=..., help=...)): a plain class attribute with no method, for simple values that need no custom handling. The value passes straight through coercion. Set default= (a bool makes it a flag, None makes it optional) and type= to control the value type — or annotate the attribute directly (attr: int = confargs.option(...)), which confargs reads as the value type. A callable default is treated as a factory (called to build the value), so tags: list[str] = option(default=list) gives a fresh [] — handy for list options that would otherwise need a mutable default.

Precedence

Highest wins: CLI > environment variables > nearest TOML > user-directory TOML > option default.

Configuration sources

TOML files

Config is read from a table named after your tool. By default that is [tool.<tool_name>] (e.g. [tool.mytool]); override it with default_config_section = "tool.custom". The file names searched are set with config_names (default ["pyproject.toml"]). Both dashed-keys and snake_case_keys are accepted.

[tool.mytool]
log = "results.html"
console = "dotted"
tags = ["ci", "nightly"]

Discovery walks up from the current directory looking for those files and stops at the project root (a directory containing .git). If nothing is found, a per-user config directory is consulted (%APPDATA%\<name> on Windows, $XDG_CONFIG_HOME/<name> otherwise). Discovery is controlled by built-in, CLI-only options:

  • --config PATH — use only this file, skip discovery.
  • --no-config — ignore config files entirely.
  • --ignore-git — keep searching above the .git project root.

By default (strict_config = True) unknown keys — and any option declared with config=False — found in the config section raise an error, which catches typos early. Set strict_config = False on your class to silently ignore them instead.

Profiles

A profile is a named set of config overrides declared under <section>.profiles.<name> in the same TOML file as your base section. Select one or more at runtime with the built-in --profile option to layer them on top of the base config:

[tool.mytool]
loglevel = "INFO"
console = "verbose"

[tool.mytool.profiles.ci]
loglevel = "DEBUG"
console = "dotted"

[tool.mytool.profiles.dev]
inherits = ["ci"]      # pull in ci's values first...
console = "verbose"    # ...then override
$ mytool --profile ci            # exact name
$ mytool --profile 'ci-*'        # glob pattern
$ mytool --profile ci --profile extra   # multiple, merged in order

Semantics (a deliberately small subset of what a full profile system offers):

  • Selection is by exact name or fnmatch glob; every pattern must match at least one profile or a ConfigDiscoveryError is raised.
  • Override, not extend — a profile's values replace the base (and earlier profiles); this holds for lists too (they are replaced, not appended).
  • inherits (a name or list of names) merges the parent profile(s) first, then the profile's own keys. Inheritance is resolved recursively; cycles are rejected.
  • precedence (integer, default 0) orders multiple selected profiles: lower is applied first, so a higher precedence wins on conflicts. Ties keep selection order.
  • enabled = false skips a directly selected profile (inherited parents always contribute).

Profiles sit in the TOML layer of the precedence chain, so command-line arguments and environment variables still win over any profile value. Profiles are read from the nearest project config only; inherits, precedence and enabled are reserved keys, not options.

Inheriting other config files (extends)

A config section can pull in one or more other config files with the reserved extends key, so shared settings live in one place and each project overrides only what it needs:

# pyproject.toml
[tool.mytool]
extends = ["../shared/base.toml", "/etc/mytool/global.toml"]
loglevel = "DEBUG"   # overrides whatever the extended files set

Semantics:

  • Paths may be relative (resolved against the file that declares extends) or absolute. A single string is accepted as shorthand for a one-item list.
  • Order — extended files are merged in the order listed, then the declaring file's own keys are applied last. So later files override earlier ones, and the declaring file always wins.
  • Override, not extend — like profiles, values (including lists) are replaced, never concatenated.
  • Recursive — an extended file may itself extends further files; every file must contain the same section ([tool.<tool_name>]). Cycles are rejected with a ConfigDiscoveryError.

extends is a reserved key (stripped before option mapping) and applies to whichever config layer declares it. Because it stays in the TOML layer, environment variables and command-line arguments still take precedence.

Environment variables

Reading from the environment is opt-in per option. Pass env=True to use a generated name, or env="MY_NAME" for an explicit one:

@option(env=True)  # reads $MYTOOL_LOG (from the class template)
def log(self, value: str = "log.html") -> str: ...


@option(env="LOG_FILE")  # reads $LOG_FILE
def log2(self, value: str = "log.html") -> str: ...

The generated name comes from the class env_var_template (default "{name}_{option}"), formatted with the tool name and the option attribute name and upper-cased — e.g. MYTOOL_LOG. Override it per class:

class Args(ArgConfig):
    tool_name = "mytool"
    env_var_template = "MYTOOL_CFG_{option}"  # -> MYTOOL_CFG_LOG

Extra arguments from an environment variable

Some tools accept a whole command line from an environment variable — ROBOT_OPTIONS, PYTEST_ADDOPTS, GREP_OPTIONS and similar. Opt in by setting options_env_var on your class:

class Args(ArgConfig):
    tool_name = "mytool"
    options_env_var = "MYTOOL_OPTIONS"

When that variable is set, its value is split with shell-like quoting and prepended to argv, so anything typed on the real command line still wins for scalar options, while repeatable options accumulate (env first, then CLI). The injected tokens go through the normal pipeline, so they may even contain an eager --argumentfile:

MYTOOL_OPTIONS="--log NONE --tag ci" mytool --tag smoke   # log=None, tags=[ci, smoke]

Quoting follows POSIX shell rules (shlex), so quote values containing spaces — and, on Windows, quote paths so their backslashes survive (MYTOOL_OPTIONS='--out "C:\build\out"').

Restricting where an option is read from

Two independent toggles control which sources feed an option:

  • @option(cli=False) hides the option from the command line (no CLI names, not shown in --help) — use for options that should only come from config files or the environment.
  • @option(config=False) stops the option being loaded from TOML config files — use for switches that control the tool run itself (the built-in discovery options above are defined this way).

Combine them as needed, e.g. a CLI-only switch is @option(config=False) with env left off.

Options in depth

  • Long names come from the method name (dry_run--dry-run); a short name is derived from the first letter when it is still free. Override either with name="console" and/or short="c" (passing name opts out of the implicit short — add short= to keep one).
  • The value type is taken from the value parameter annotation. bool becomes a flag; list[...] becomes a repeatable option; int/float/str are coerced from strings. confargs performs this coercion before calling your method, so the value you receive already matches the annotated type.
  • Your method receives the coerced value and returns the final one. Whatever it returns is stored as-is — including None, which is a legitimate value (e.g. a --log NONE that disables a file). There is no special "return nothing to keep the input" behaviour: if the method has no parsing or validation to do, declare the option as a plain attribute instead so the coerced value passes straight through. Raise confargs.OptionValueError to reject a value.
  • Boolean options can be negated on the command line: --verbose sets it to True, --no-verbose sets it to False.
  • A value that itself looks like a registered option (e.g. passing -v as the value of --name when -v is a known short option) is otherwise read as the next option. Use the attached form to force it as a value: --name=-v (or -n-v for a short option).

Lenient command-line names (case, hyphens and abbreviation)

By default long options must be spelled exactly as declared. Three opt-in class attributes relax this on the command line only (config-file keys are always matched exactly):

class MyArgs(ArgConfig):
    cli_case_insensitive = True  # --VariableFile == --variablefile
    cli_ignore_hyphens = True  # --variable-file == --variablefile
    cli_allow_abbrev = True  # --var == --variablefile (if unambiguous)

    variablefile: list[str] = option(name="variablefile", default=list)
    statusrc: bool = option(name="statusrc", default=False)

With both enabled, --variablefile, --variable-file, --VariableFile and --VARIABLE-FILE all resolve to the same option, and a flag can be negated as --no-statusrc, --nostatusrc or --No-StatusRc. Enable only one attribute to relax just case or just hyphens. If two options would collide once normalised (e.g. --foo-bar and --foobar with cli_ignore_hyphens), the lenient fallback is dropped for that pair and only their exact spellings work.

cli_allow_abbrev additionally accepts any unambiguous prefix of a long name (--var for --variablefile), matching the behaviour of argparse and most GNU tools. An exact match always wins over a prefix (so --log stays --log even when --loglevel exists), and an ambiguous prefix raises an error listing the candidates. It composes with the two leniency toggles, so with all three on --Var-File resolves as well. Short options are never abbreviated (-n only ever matches a real -n, never a prefix of --name).

These toggles never affect configuration files: a [tool.mytool] table must use the option's declared name (its underscore/hyphen variants are still interchangeable, but case is significant).

Restricting a value to a set of choices

Annotate an option (or argument) with typing.Literal[...] to constrain it to a fixed set of allowed values. confargs coerces the incoming value to the members' type and then rejects anything outside the set with an OptionValueError; the allowed values are also shown in --help:

from typing import Literal

from confargs import ArgConfig, option


class Args(ArgConfig):
    console: Literal["verbose", "dotted", "quiet", "none"] = option(name="console", default="verbose")
    level: Literal[1, 2, 3] = option(name="level", default=1)
    langs: list[Literal["en", "pl"]] = option(name="langs", default=list)

The Literal may be optional (Literal["a", "b"] | None), wrapped in list[...] for repeatable options, or supplied on a method's value parameter. Non-string members (e.g. Literal[1, 2, 3]) are coerced before the membership check.

By default the match is case-sensitive. Pass ignore_case=True to accept any casing and normalise the result to the spelling declared in the Literal — handy when a canonical form differs from what users type:

class Args(ArgConfig):
    # ``--colors on`` / ``--colors On`` / ``--colors ON`` all yield "ON".
    colors: Literal["AUTO", "ON", "OFF", "ANSI"] = option(name="colors", default="AUTO", ignore_case=True)

An unknown value is still rejected, and a case-insensitive match is only honoured when it is unambiguous.

Eager options and argument files

Mark an option is_eager=True to resolve it before every other source, directly against argv. The method's return value — an iterable of tokens or None — replaces the option's own arguments, so it can inject more options. This is how an --argumentfile option expands a file (Robot Framework style) into extra arguments, including nested argument files:

from confargs import ArgConfig, option, read_argument_file


class Args(ArgConfig):
    @option(name="argumentfile", short="A", config=False, is_eager=True)
    def argumentfile(self, value: str | None = None) -> list[str] | None:
        return read_argument_file(value) if value else None

ConfigurationProcessor(Args, argv=[...]) accepts an explicit argument list; when omitted it falls back to sys.argv[1:].

Argument files are read as utf-8-sig, so a leading UTF-8 BOM is ignored. Each line is stripped; blank lines and # comments are skipped; an option line is split into a name and value on the first space or =. When the first line is a truthy # expandvars: <bool> pragma, the whole file is expanded first — $NAME, ${NAME} and ${NAME=default} pull from the environment (pass a custom environ= mapping to read_argument_file/split_argument_file to override), $$ is a literal $, and an unset variable without a default (or a malformed reference) raises CliUsageError.

Positional arguments

Options are addressed by name; arguments are positional — filled from the leftover, non-option tokens in declaration order. They mirror the two option spellings (a method for parsing/validation, or a plain attribute for pass-through) and share the same coercion path. Declare them with confargs.argument(...):

import confargs
from confargs import ArgConfig, argument


class Runner(ArgConfig):
    tool_name = "runner"

    # A required single positional.
    suite = argument(name="suite", help="Suite file to run.")

    # An optional one (used only when present).
    tag = argument(name="tag", nargs="?", default=None, help="Only run this tag.")

    # A variadic one that collects the rest into a list.
    @argument(nargs="*")
    def data_sources(self, value: list[str]) -> list[str]:
        """Extra data source paths."""
        return value

nargs controls how many positionals an argument consumes:

  • 1 (default) — exactly one; required unless a default is given.
  • "?" — at most one; the default is used when it is absent.
  • "*" — zero or more, collected into a list (default []).
  • "+" — one or more, collected into a list; required.

Only one variadic argument ("*"/"+") is allowed and it must be declared last. Arguments are also read from TOML config by their name (suite = "smoke.robot" in the tool's section), with command-line positionals taking precedence. Resolved values appear on the Namespace alongside options — so avoid names that clash with Namespace methods (keys, values, items, as_dict).

Example

A complete, self-contained example lives in examples/demo.py (with a sample examples/example.args and examples/README.md). It's a single copy-pasteable file showing value options, --no- flag negation, environment variables and an eager --argumentfile. Run it from a checkout without installing anything:

uv run python examples/demo.py --who Ada --repeat 3
uv run python examples/demo.py -A examples/example.args
uv run python examples/demo.py --help

Separately, the packaged confargs.demo module is installed as the confargs-demo console script via [project.scripts]:

uv run confargs-demo --console quiet --retries 5
uv run confargs-demo --help

To ship your own tool, point a console script at a main() that runs the processor, for example in pyproject.toml:

[project.scripts]
mytool = "mytool.cli:main"

Shell completion

Every tool built with confargs gets tab-completion for bash, zsh, fish and PowerShell for free — no extra dependency, no per-tool setup. Two builtin options drive it (both take the target shell as their value):

# Print the completion script (inspect it, or source it directly):
mytool --show-completion bash
eval "$(mytool --show-completion bash)"

# Or install it into the shell's standard location and reload your shell:
mytool --install-completion zsh

The supported shell values are bash, zsh, fish, powershell and pwsh. On Windows, use powershell for Windows PowerShell 5.1 (profile under Documents\WindowsPowerShell) and pwsh for PowerShell 7+ (profile under Documents\PowerShell) — install into the one you actually run.

Completion is dynamic: the shell re-invokes your program to ask what to suggest, so candidates always reflect the options you have declared. It completes long and short option names, --no- negations for boolean flags, and — where an option or argument restricts its value with typing.Literal[...] — the allowed choices. Options whose value is not a fixed set fall back to the shell's own file/directory completion.

Under the hood the shell sets a _<PROG>_COMPLETE environment variable when it wants suggestions; ConfigurationProcessor.process() detects it, prints the candidates and exits before any of your option methods run.

Development

This project uses uv.

uv sync                 # create the environment
uv run pytest           # run the tests
uv run ruff check       # lint
uv run ruff format      # format
uv run mypy             # type-check
pre-commit install      # enable git hooks

Publishing

Releases are published to PyPI by .github/workflows/publish.yml when a GitHub Release is published. It uses PyPI Trusted Publishing (OIDC), so no API token is stored in the repository — configure the project as a trusted publisher on PyPI (workflow publish.yml, environment pypi) once.

Versioning

confargs follows Semantic Versioning. The version is single-sourced from __version__ in src/confargs/__init__.py (hatchling reads it at build time). While the project is 0.x.y the API is still stabilising, so minor releases may include breaking changes. Notable changes are recorded in CHANGELOG.md.

Releases are automated with release-please: merging Conventional Commits to main keeps an open release PR that bumps __version__, updates the changelog and, once merged, tags the release and publishes to PyPI. Pre-1.0, breaking changes bump the minor version (bump-minor-pre-major).

License

MIT

About

CLI arguments and TOML config parser.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages