Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

## What is argclass

Declarative CLI parser for Python over `argparse`. Type hints → CLI args automatically. `str`→string, `bool`→flag, `Optional[T]`→optional, `list[T]`→multi-value, `Literal[...]`→choices. Priority: defaults < config files < env vars < CLI args. Zero deps, stdlib only, Python 3.10-3.14.
Declarative CLI parser for Python over `argparse`. Type hints → CLI args automatically. `str`→string, `bool`→flag, `Optional[T]`→optional, `list[T]`→multi-value, `Literal[...]`→choices. Priority: defaults < config files < `config_argument` file < env vars < CLI args. Zero deps, stdlib only, Python 3.10-3.14.

`Parser(config_argument="--config")` adds a CLI flag whose file becomes argument defaults at invocation time (two-pass parsing); `config_files=` is the developer-preset equivalent.

## Commands

Expand Down
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,32 @@ parser = Parser(config_files=[
])
```

To let the **end user** choose the config file, add
`config_argument="--config"` — the flag's file becomes argument
defaults (CLI and env vars still win):

<!--- name: test_config_argument_example --->
```python
import argclass
from pathlib import Path
from tempfile import NamedTemporaryFile

class Parser(argclass.Parser):
host: str = "localhost"
port: int = 8080

with NamedTemporaryFile(mode="w", suffix=".ini", delete=False) as f:
f.write("[DEFAULT]\nhost = example.com\nport = 9000\n")
config_path = f.name

parser = Parser(config_argument="--config")
parser.parse_args(["--config", config_path, "--port", "1234"])
assert parser.host == "example.com" # default from the file
assert parser.port == 1234 # CLI still wins

Path(config_path).unlink()
```

### Environment Variables

<!--- name: test_env_example --->
Expand Down
5 changes: 4 additions & 1 deletion argclass/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,10 @@ def __call__(
if self._result is None:
filenames: Sequence[Path] = list(self.search_paths)
if values:
filenames = [Path(values)] + list(filenames)
# The explicitly passed file goes LAST: parse() merges
# files in order with dict.update(), so later files
# win and an explicit --config overrides search_paths.
filenames = list(filenames) + [Path(values)]
filenames = list(filter(lambda x: x.exists(), filenames))

if self.required and not filenames:
Expand Down
173 changes: 169 additions & 4 deletions argclass/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import copy
import os
import sys
import weakref
from abc import ABCMeta
from argparse import Action, ArgumentParser
Expand All @@ -26,6 +27,7 @@
ArgclassError,
ArgumentDefinitionError,
ComplexTypeError,
ConfigurationError,
TypeConversionError,
)
from .secret import SecretString
Expand Down Expand Up @@ -618,6 +620,22 @@ class — a second ``parse_args()`` on another instance would
ParserType = TypeVar("ParserType", bound="Parser")


class PreScanError(Exception):
"""Raised instead of SystemExit by the config pre-scan parser."""


class PreScanParser(ArgumentParser):
"""ArgumentParser that neither prints to stderr nor exits.

Used for the lightweight first pass that only extracts the
``config_argument`` value from argv; any syntax problem is left
for the real parser to report with the full usage text.
"""

def error(self, message: str) -> Any: # type: ignore[override]
raise PreScanError(message)


# Out-of-band back-references: argparse_parser -> argclass Parser.
# Lets custom actions (e.g. ``GenerateConfigAction``) recover the
# argclass Parser without mutating any argparse object. Auto-cleans
Expand Down Expand Up @@ -749,12 +767,62 @@ def __init__(
auto_env_var_prefix: str | None = None,
strict_config: bool = False,
config_parser_class: type[AbstractDefaultsParser] = INIDefaultsParser,
config_argument: str | Iterable[str] | None = None,
**kwargs: Any,
):
"""
Args:
config_files: Paths the DEVELOPER presets; existing files
are read at construction time and their values become
argument defaults (later files override earlier ones).
auto_env_var_prefix: Derive an environment variable name
for every argument (``PREFIX_ARG_NAME``); env values
override config-file defaults.
strict_config: Raise on malformed ``config_files`` instead
of skipping them.
config_parser_class: Format for both ``config_files`` and
``config_argument`` (INI by default; pass
``JSONDefaultsParser`` / ``TOMLDefaultsParser`` or a
custom ``AbstractDefaultsParser`` subclass).
config_argument: CLI flag (or several aliases, e.g.
``("-c", "--config")``) that lets the END USER point
at a config file whose values become argument
defaults. Resolved before the main parse, so
``--help`` reflects the file and required arguments
are satisfied by it. Priority: declared defaults <
``config_files`` < ``config_argument`` file < env
vars < CLI args. An explicitly passed path that is
missing or unparsable raises ``ConfigurationError``.
kwargs: Passed through to ``argparse.ArgumentParser``
(e.g. ``prog``, ``description``, ``epilog``).
"""
super().__init__()
self.current_subparsers: tuple[AbstractParser, ...] = ()
self._config_files = config_files

# ``config_argument`` adds a CLI flag (e.g. "--config") that
# lets the END USER point at a config file whose values become
# argument defaults — same effect as ``config_files``, but the
# path is chosen at invocation time. Same format/parser class.
if config_argument is None:
self._config_argument: tuple[str, ...] = ()
elif isinstance(config_argument, str):
self._config_argument = (config_argument,)
else:
self._config_argument = tuple(config_argument)
for alias in self._config_argument:
if not alias.startswith("-"):
raise ArgumentDefinitionError(
f"config_argument aliases must be optional flags "
f"(start with '-'), got {alias!r}",
aliases=self._config_argument,
hint='Use config_argument="--config" or a tuple '
'of flags like ("-c", "--config").',
)
self._config_parser_class = config_parser_class
self._runtime_config_parsers: list[AbstractDefaultsParser] = []
self._user_config_files: tuple[Path, ...] = ()

# Parse config files using the specified parser class
self._config_parser = config_parser_class(
config_files, strict=strict_config
Expand Down Expand Up @@ -828,6 +896,82 @@ def current_subparser(self) -> "AbstractParser | None":
return None
return self.current_subparsers[0]

@property
def loaded_config_files(self) -> tuple[Path, ...]:
"""Configuration files whose values were applied as defaults:
constructor ``config_files`` first, then the file passed via
``config_argument`` (highest priority last)."""
return tuple(self._config_parser.loaded_files) + self._user_config_files

def _scan_config_argument(self, argv: list[str]) -> str | None:
"""First pass over argv: extract only the config flag value.

Defaults must be known before the real parser is built, but
the config path is itself a CLI argument — the classic
chicken-and-egg of argparse. A throwaway parser that knows
only the config flag resolves it.
"""
prescan = PreScanParser(add_help=False)
prescan.add_argument(*self._config_argument, dest="path", default=None)
try:
namespace, _ = prescan.parse_known_args(argv)
except PreScanError:
# Malformed usage (e.g. the flag without a value); the
# real parser will report it with the proper usage text.
return None
return namespace.path

def _load_config_argument(
self, argv: list[str]
) -> list[AbstractDefaultsParser]:
if not self._config_argument:
return []
self._user_config_files = ()
path = self._scan_config_argument(argv)
if path is None:
return []
file = Path(path).expanduser()
# The user asked for this file explicitly, so problems are
# loud errors — unlike constructor ``config_files``, which
# are a search list where absent files are normal.
runtime_parser = self._config_parser_class([file], strict=True)
try:
runtime_parser.parse()
except ConfigurationError:
raise
except Exception as e:
raise ConfigurationError(
f"failed to parse configuration file: {e}",
file_path=str(file),
) from e
if not runtime_parser.loaded_files:
raise ConfigurationError(
"configuration file does not exist or is not readable",
file_path=str(file),
hint="Check the path passed via "
f"{'/'.join(self._config_argument)}.",
)
self._user_config_files = runtime_parser.loaded_files
return [runtime_parser]

def _get_config_default(
self,
name: str,
kind: ValueKind,
section: str | None = None,
) -> Any:
"""Look up a config-provided default for ``name``.

The file passed via ``config_argument`` (when present) wins
over the constructor ``config_files``; env vars and CLI args
still override both later in the chain.
"""
for runtime_parser in self._runtime_config_parsers:
value = runtime_parser.get_value(name, kind, section=section)
if value is not None:
return value
return self._config_parser.get_value(name, kind, section=section)

def _make_parser(
self,
parser: ArgumentParser | None = None,
Expand All @@ -841,6 +985,15 @@ def _make_parser(

_argclass_back_refs[parser] = self

if self._config_argument:
parser.add_argument(
*self._config_argument,
default=None,
metavar="FILE",
help="Read default values for the other arguments "
"from this configuration file",
)

destinations: DestinationsType = defaultdict(set)
self._fill_arguments(destinations, parser)
self._fill_groups(destinations, parser)
Expand Down Expand Up @@ -893,7 +1046,7 @@ def _fill_arguments(

# Get default from config with type-aware loading
kind = self._get_value_kind(argument)
config_default = self._config_parser.get_value(name, kind)
config_default = self._get_config_default(name, kind)

# Apply type converter to config values
if config_default is not None and argument.type is not None:
Expand Down Expand Up @@ -992,7 +1145,7 @@ def _fill_group(

# Get default from config with type-aware loading
kind = self._get_value_kind(argument)
config_default = self._config_parser.get_value(
config_default = self._get_config_default(
name,
kind,
section=section,
Expand Down Expand Up @@ -1099,8 +1252,20 @@ def parse_args(
args: list[str] | None = None,
sanitize_secrets: bool = False,
) -> ParserType:
parser, destinations = self._make_parser()
parsed_ns = parser.parse_args(args=args)
argv = list(args) if args is not None else sys.argv[1:]
# Two-pass parsing: resolve the ``config_argument`` file first
# so its values are already baked in as argument defaults when
# the real parser is built (this also makes ``--help`` show
# the file-provided defaults). The runtime layer lives only
# for the duration of this parse.
try:
self._runtime_config_parsers = self._load_config_argument(
argv,
)
parser, destinations = self._make_parser()
parsed_ns = parser.parse_args(args=argv)
finally:
self._runtime_config_parsers = []

# Get the chain of selected subparsers from the namespace
selected_subparsers: tuple[AbstractParser, ...] = getattr(
Expand Down
10 changes: 10 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"""Repo-wide pytest configuration."""

import os

# Python 3.14+ argparse colorizes usage/help output; CI sets
# FORCE_COLOR=1, which would inject ANSI escapes into captured output
# and break substring assertions on help text. PYTHON_COLORS takes
# precedence over FORCE_COLOR/NO_COLOR, pinning plain output
# deterministically for every Python version and environment.
os.environ["PYTHON_COLORS"] = "0"
7 changes: 5 additions & 2 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,8 +202,11 @@ Path(config_path).unlink()
```

:::{tip}
For loading defaults into parser attributes, use `config_files` parameter instead.
See [Config File Parsers](#config-file-parsers).
For loading defaults into parser attributes, use the `config_files`
parameter (developer-chosen paths) or `config_argument="--config"`
(end-user-chosen path) instead. See
[Config Files](config-files.md#user-supplied-config-file-config_argument)
and [Config File Parsers](#config-file-parsers).
:::

```{eval-rst}
Expand Down
52 changes: 52 additions & 0 deletions docs/config-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,58 @@ assert parser.debug is True
Path(config_path).unlink()
```

## User-Supplied Config File (`config_argument`)

`config_files=` is chosen by the developer at construction time. To
let the **end user** point at a config file, pass
`config_argument="--config"` — argclass adds the flag and applies the
file's values as argument defaults via two-pass parsing (the flag is
resolved first, then the real parser is built with the defaults in
place, so even `--help` shows them):

<!--- name: test_config_argument --->
```python
import argclass
from pathlib import Path
from tempfile import NamedTemporaryFile

class Parser(argclass.Parser):
host: str = "localhost"
port: int = 8080

with NamedTemporaryFile(mode="w", suffix=".ini", delete=False) as f:
f.write("[DEFAULT]\nhost = example.com\nport = 9000\n")
config_path = f.name

parser = Parser(config_argument="--config")
parser.parse_args(["--config", config_path, "--port", "1234"])

assert parser.host == "example.com" # default from the file
assert parser.port == 1234 # CLI still wins

Path(config_path).unlink()
```

Details:

- The priority chain extends naturally: declared defaults <
`config_files` < `config_argument` file < env vars < CLI args.
- The file format is the shared `config_parser_class` (INI by
default; pass `JSONDefaultsParser` / `TOMLDefaultsParser` for other
formats).
- A required argument is satisfied by a value from the file.
- Several aliases are accepted: `config_argument=("-c", "--config")`.
- An explicitly passed path that does not exist or cannot be parsed
raises `ConfigurationError` — unlike `config_files`, which is a
lenient search list.
- `parser.loaded_config_files` reports which files were applied, in
priority order.
- The flag is resolved by the parser whose `parse_args()` you call;
put it before any subcommand on the command line.

This is different from `argclass.Config()`, which loads a file into
an attribute as raw data without touching other arguments' defaults.

## Supported Formats

::::{grid} 3
Expand Down
3 changes: 3 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ and seamless integration with configuration files and environment variables.
- **Multiple config formats** - Load defaults from INI, JSON, or TOML configuration
files with automatic type conversion. Each format has built-in support with
no additional dependencies (TOML requires Python 3.11+ or `tomli` package).
- **User-supplied config** - `config_argument="--config"` adds a CLI flag so
the end user can point at a config file whose values become argument
defaults at invocation time.
- **Environment variables** - Read configuration from environment variables
with optional prefix support for namespacing.
- **Secret handling** - Built-in support for sensitive values that are masked
Expand Down
1 change: 1 addition & 0 deletions docs/pitfalls.md
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,7 @@ type mismatches raise `ConfigurationError`.
| Malformed INI/JSON/TOML | `ConfigurationError` with file path |
| Value doesn't match type | `ConfigurationError` with field and section |
| Missing file | Silently ignored (unless `strict_config=True`) |
| Missing/malformed `config_argument` file | Always `ConfigurationError` (the user asked for it explicitly) |

<!--- name: test_pitfall_config_ok --->
```python
Expand Down
Loading
Loading