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:
- Command line arguments (
--log out.html,-l NONE) - Environment variables (per-option or auto-generated)
- 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. Setdefault=(aboolmakes it a flag,Nonemakes it optional) andtype=to control the value type — or annotate the attribute directly (attr: int = confargs.option(...)), which confargs reads as the value type. A callabledefaultis treated as a factory (called to build the value), sotags: 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.gitproject 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
fnmatchglob; every pattern must match at least one profile or aConfigDiscoveryErroris 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, default0) orders multiple selected profiles: lower is applied first, so a higherprecedencewins on conflicts. Ties keep selection order.enabled = falseskips 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
extendsfurther files; every file must contain the same section ([tool.<tool_name>]). Cycles are rejected with aConfigDiscoveryError.
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 withname="console"and/orshort="c"(passingnameopts out of the implicit short — addshort=to keep one). - The value type is taken from the
valueparameter annotation.boolbecomes a flag;list[...]becomes a repeatable option;int/float/strare coerced from strings. confargs performs this coercion before calling your method, so thevalueyou 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 NONEthat 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. Raiseconfargs.OptionValueErrorto reject a value. - Boolean options can be negated on the command line:
--verbosesets it toTrue,--no-verbosesets it toFalse. - A value that itself looks like a registered option (e.g. passing
-vas the value of--namewhen-vis 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-vfor 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.
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 adefaultis given."?"— at most one; thedefaultis 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"
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
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file confargs-0.8.0.tar.gz.
File metadata
- Download URL: confargs-0.8.0.tar.gz
- Upload date:
- Size: 125.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5bd75bcf93c7fd4b03ecff052b9181f7df8ad7037563465fa9a79a1cb02f0f57
|
|
| MD5 |
39cd245df00dc7f18c42cf5293c18653
|
|
| BLAKE2b-256 |
e5f3fa100fd7fd24d2e5bf5e5935b7cfce75c39d7aae359e80197328d1c612f4
|
Provenance
The following attestation bundles were made for confargs-0.8.0.tar.gz:
Publisher:
release-please.yml on MarketSquare/confargs
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
confargs-0.8.0.tar.gz -
Subject digest:
5bd75bcf93c7fd4b03ecff052b9181f7df8ad7037563465fa9a79a1cb02f0f57 - Sigstore transparency entry: 2665595892
- Sigstore integration time:
-
Permalink:
MarketSquare/confargs@1ccaf6531bbfd33e0cc0930d77609c9573d4b342 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/MarketSquare
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release-please.yml@1ccaf6531bbfd33e0cc0930d77609c9573d4b342 -
Trigger Event:
push
-
Statement type:
File details
Details for the file confargs-0.8.0-py3-none-any.whl.
File metadata
- Download URL: confargs-0.8.0-py3-none-any.whl
- Upload date:
- Size: 43.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
21b2d2cbc239950dcdafd03e9bed2736b34bd4eb559190631b736aa8e17f8b81
|
|
| MD5 |
0931d0afcc317e8f1c3daace5d5d9931
|
|
| BLAKE2b-256 |
56dba18356ea5f4596b1b5e5c53a6b4d13c8c1dce8f107e773b87443c54fddf6
|
Provenance
The following attestation bundles were made for confargs-0.8.0-py3-none-any.whl:
Publisher:
release-please.yml on MarketSquare/confargs
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
confargs-0.8.0-py3-none-any.whl -
Subject digest:
21b2d2cbc239950dcdafd03e9bed2736b34bd4eb559190631b736aa8e17f8b81 - Sigstore transparency entry: 2665595957
- Sigstore integration time:
-
Permalink:
MarketSquare/confargs@1ccaf6531bbfd33e0cc0930d77609c9573d4b342 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/MarketSquare
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release-please.yml@1ccaf6531bbfd33e0cc0930d77609c9573d4b342 -
Trigger Event:
push
-
Statement type: