Skip to content

Commit 6d7332d

Browse files
committed
Add config_argument: user-supplied config file as argument defaults
config_files= lets the developer preset defaults from files chosen at construction time. The new Parser(config_argument="--config") adds a CLI flag so the END USER can point at a config file whose values become argument defaults, resolved at invocation time. Implementation is two-pass parsing: a throwaway pre-scan parser (that neither prints nor exits) extracts only the config path from argv; the file is loaded through the shared config_parser_class machinery and layered over the constructor config defaults; then the real parser is built — so required-relaxation, type-aware conversion, group sections, and --help defaults all come from the existing defaults pipeline. Priority chain extends naturally: declared defaults < config_files < config_argument file < env < CLI Properties of the feature: - accepts one flag or several aliases: config_argument=("-c", "--config") - an explicitly passed path that is missing or malformed raises ConfigurationError (config_files stays a lenient search list) - no cascade into subparsers (consistent with config_files) - runtime defaults live only for the duration of one parse_args call - new Parser.loaded_config_files property reports applied files in priority order Also fixes a related pre-existing bug in ConfigAction: an explicit --config file was merged BEFORE search_paths, so preset files silently overrode the user's explicit choice; the explicit file now wins.
1 parent e0404fc commit 6d7332d

9 files changed

Lines changed: 520 additions & 8 deletions

File tree

CLAUDE.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
## What is argclass
44

5-
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.
5+
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.
6+
7+
`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.
68

79
## Commands
810

README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,32 @@ parser = Parser(config_files=[
169169
])
170170
```
171171

172+
To let the **end user** choose the config file, add
173+
`config_argument="--config"` — the flag's file becomes argument
174+
defaults (CLI and env vars still win):
175+
176+
<!--- name: test_config_argument_example --->
177+
```python
178+
import argclass
179+
from pathlib import Path
180+
from tempfile import NamedTemporaryFile
181+
182+
class Parser(argclass.Parser):
183+
host: str = "localhost"
184+
port: int = 8080
185+
186+
with NamedTemporaryFile(mode="w", suffix=".ini", delete=False) as f:
187+
f.write("[DEFAULT]\nhost = example.com\nport = 9000\n")
188+
config_path = f.name
189+
190+
parser = Parser(config_argument="--config")
191+
parser.parse_args(["--config", config_path, "--port", "1234"])
192+
assert parser.host == "example.com" # default from the file
193+
assert parser.port == 1234 # CLI still wins
194+
195+
Path(config_path).unlink()
196+
```
197+
172198
### Environment Variables
173199

174200
<!--- name: test_env_example --->

argclass/actions.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,10 @@ def __call__(
7676
if self._result is None:
7777
filenames: Sequence[Path] = list(self.search_paths)
7878
if values:
79-
filenames = [Path(values)] + list(filenames)
79+
# The explicitly passed file goes LAST: parse() merges
80+
# files in order with dict.update(), so later files
81+
# win and an explicit --config overrides search_paths.
82+
filenames = list(filenames) + [Path(values)]
8083
filenames = list(filter(lambda x: x.exists(), filenames))
8184

8285
if self.required and not filenames:

argclass/parser.py

Lines changed: 169 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import copy
44
import os
5+
import sys
56
import weakref
67
from abc import ABCMeta
78
from argparse import Action, ArgumentParser
@@ -26,6 +27,7 @@
2627
ArgclassError,
2728
ArgumentDefinitionError,
2829
ComplexTypeError,
30+
ConfigurationError,
2931
TypeConversionError,
3032
)
3133
from .secret import SecretString
@@ -618,6 +620,22 @@ class — a second ``parse_args()`` on another instance would
618620
ParserType = TypeVar("ParserType", bound="Parser")
619621

620622

623+
class PreScanError(Exception):
624+
"""Raised instead of SystemExit by the config pre-scan parser."""
625+
626+
627+
class PreScanParser(ArgumentParser):
628+
"""ArgumentParser that neither prints to stderr nor exits.
629+
630+
Used for the lightweight first pass that only extracts the
631+
``config_argument`` value from argv; any syntax problem is left
632+
for the real parser to report with the full usage text.
633+
"""
634+
635+
def error(self, message: str) -> Any: # type: ignore[override]
636+
raise PreScanError(message)
637+
638+
621639
# Out-of-band back-references: argparse_parser -> argclass Parser.
622640
# Lets custom actions (e.g. ``GenerateConfigAction``) recover the
623641
# argclass Parser without mutating any argparse object. Auto-cleans
@@ -749,12 +767,62 @@ def __init__(
749767
auto_env_var_prefix: str | None = None,
750768
strict_config: bool = False,
751769
config_parser_class: type[AbstractDefaultsParser] = INIDefaultsParser,
770+
config_argument: str | Iterable[str] | None = None,
752771
**kwargs: Any,
753772
):
773+
"""
774+
Args:
775+
config_files: Paths the DEVELOPER presets; existing files
776+
are read at construction time and their values become
777+
argument defaults (later files override earlier ones).
778+
auto_env_var_prefix: Derive an environment variable name
779+
for every argument (``PREFIX_ARG_NAME``); env values
780+
override config-file defaults.
781+
strict_config: Raise on malformed ``config_files`` instead
782+
of skipping them.
783+
config_parser_class: Format for both ``config_files`` and
784+
``config_argument`` (INI by default; pass
785+
``JSONDefaultsParser`` / ``TOMLDefaultsParser`` or a
786+
custom ``AbstractDefaultsParser`` subclass).
787+
config_argument: CLI flag (or several aliases, e.g.
788+
``("-c", "--config")``) that lets the END USER point
789+
at a config file whose values become argument
790+
defaults. Resolved before the main parse, so
791+
``--help`` reflects the file and required arguments
792+
are satisfied by it. Priority: declared defaults <
793+
``config_files`` < ``config_argument`` file < env
794+
vars < CLI args. An explicitly passed path that is
795+
missing or unparsable raises ``ConfigurationError``.
796+
kwargs: Passed through to ``argparse.ArgumentParser``
797+
(e.g. ``prog``, ``description``, ``epilog``).
798+
"""
754799
super().__init__()
755800
self.current_subparsers: tuple[AbstractParser, ...] = ()
756801
self._config_files = config_files
757802

803+
# ``config_argument`` adds a CLI flag (e.g. "--config") that
804+
# lets the END USER point at a config file whose values become
805+
# argument defaults — same effect as ``config_files``, but the
806+
# path is chosen at invocation time. Same format/parser class.
807+
if config_argument is None:
808+
self._config_argument: tuple[str, ...] = ()
809+
elif isinstance(config_argument, str):
810+
self._config_argument = (config_argument,)
811+
else:
812+
self._config_argument = tuple(config_argument)
813+
for alias in self._config_argument:
814+
if not alias.startswith("-"):
815+
raise ArgumentDefinitionError(
816+
f"config_argument aliases must be optional flags "
817+
f"(start with '-'), got {alias!r}",
818+
aliases=self._config_argument,
819+
hint='Use config_argument="--config" or a tuple '
820+
'of flags like ("-c", "--config").',
821+
)
822+
self._config_parser_class = config_parser_class
823+
self._runtime_config_parsers: list[AbstractDefaultsParser] = []
824+
self._user_config_files: tuple[Path, ...] = ()
825+
758826
# Parse config files using the specified parser class
759827
self._config_parser = config_parser_class(
760828
config_files, strict=strict_config
@@ -828,6 +896,82 @@ def current_subparser(self) -> "AbstractParser | None":
828896
return None
829897
return self.current_subparsers[0]
830898

899+
@property
900+
def loaded_config_files(self) -> tuple[Path, ...]:
901+
"""Configuration files whose values were applied as defaults:
902+
constructor ``config_files`` first, then the file passed via
903+
``config_argument`` (highest priority last)."""
904+
return tuple(self._config_parser.loaded_files) + self._user_config_files
905+
906+
def _scan_config_argument(self, argv: list[str]) -> str | None:
907+
"""First pass over argv: extract only the config flag value.
908+
909+
Defaults must be known before the real parser is built, but
910+
the config path is itself a CLI argument — the classic
911+
chicken-and-egg of argparse. A throwaway parser that knows
912+
only the config flag resolves it.
913+
"""
914+
prescan = PreScanParser(add_help=False)
915+
prescan.add_argument(*self._config_argument, dest="path", default=None)
916+
try:
917+
namespace, _ = prescan.parse_known_args(argv)
918+
except PreScanError:
919+
# Malformed usage (e.g. the flag without a value); the
920+
# real parser will report it with the proper usage text.
921+
return None
922+
return namespace.path
923+
924+
def _load_config_argument(
925+
self, argv: list[str]
926+
) -> list[AbstractDefaultsParser]:
927+
if not self._config_argument:
928+
return []
929+
self._user_config_files = ()
930+
path = self._scan_config_argument(argv)
931+
if path is None:
932+
return []
933+
file = Path(path).expanduser()
934+
# The user asked for this file explicitly, so problems are
935+
# loud errors — unlike constructor ``config_files``, which
936+
# are a search list where absent files are normal.
937+
runtime_parser = self._config_parser_class([file], strict=True)
938+
try:
939+
runtime_parser.parse()
940+
except ConfigurationError:
941+
raise
942+
except Exception as e:
943+
raise ConfigurationError(
944+
f"failed to parse configuration file: {e}",
945+
file_path=str(file),
946+
) from e
947+
if not runtime_parser.loaded_files:
948+
raise ConfigurationError(
949+
"configuration file does not exist or is not readable",
950+
file_path=str(file),
951+
hint="Check the path passed via "
952+
f"{'/'.join(self._config_argument)}.",
953+
)
954+
self._user_config_files = runtime_parser.loaded_files
955+
return [runtime_parser]
956+
957+
def _get_config_default(
958+
self,
959+
name: str,
960+
kind: ValueKind,
961+
section: str | None = None,
962+
) -> Any:
963+
"""Look up a config-provided default for ``name``.
964+
965+
The file passed via ``config_argument`` (when present) wins
966+
over the constructor ``config_files``; env vars and CLI args
967+
still override both later in the chain.
968+
"""
969+
for runtime_parser in self._runtime_config_parsers:
970+
value = runtime_parser.get_value(name, kind, section=section)
971+
if value is not None:
972+
return value
973+
return self._config_parser.get_value(name, kind, section=section)
974+
831975
def _make_parser(
832976
self,
833977
parser: ArgumentParser | None = None,
@@ -841,6 +985,15 @@ def _make_parser(
841985

842986
_argclass_back_refs[parser] = self
843987

988+
if self._config_argument:
989+
parser.add_argument(
990+
*self._config_argument,
991+
default=None,
992+
metavar="FILE",
993+
help="Read default values for the other arguments "
994+
"from this configuration file",
995+
)
996+
844997
destinations: DestinationsType = defaultdict(set)
845998
self._fill_arguments(destinations, parser)
846999
self._fill_groups(destinations, parser)
@@ -893,7 +1046,7 @@ def _fill_arguments(
8931046

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

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

9931146
# Get default from config with type-aware loading
9941147
kind = self._get_value_kind(argument)
995-
config_default = self._config_parser.get_value(
1148+
config_default = self._get_config_default(
9961149
name,
9971150
kind,
9981151
section=section,
@@ -1099,8 +1252,20 @@ def parse_args(
10991252
args: list[str] | None = None,
11001253
sanitize_secrets: bool = False,
11011254
) -> ParserType:
1102-
parser, destinations = self._make_parser()
1103-
parsed_ns = parser.parse_args(args=args)
1255+
argv = list(args) if args is not None else sys.argv[1:]
1256+
# Two-pass parsing: resolve the ``config_argument`` file first
1257+
# so its values are already baked in as argument defaults when
1258+
# the real parser is built (this also makes ``--help`` show
1259+
# the file-provided defaults). The runtime layer lives only
1260+
# for the duration of this parse.
1261+
try:
1262+
self._runtime_config_parsers = self._load_config_argument(
1263+
argv,
1264+
)
1265+
parser, destinations = self._make_parser()
1266+
parsed_ns = parser.parse_args(args=argv)
1267+
finally:
1268+
self._runtime_config_parsers = []
11041269

11051270
# Get the chain of selected subparsers from the namespace
11061271
selected_subparsers: tuple[AbstractParser, ...] = getattr(

docs/api.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -202,8 +202,11 @@ Path(config_path).unlink()
202202
```
203203

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

209212
```{eval-rst}

docs/config-files.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,58 @@ assert parser.debug is True
3939
Path(config_path).unlink()
4040
```
4141

42+
## User-Supplied Config File (`config_argument`)
43+
44+
`config_files=` is chosen by the developer at construction time. To
45+
let the **end user** point at a config file, pass
46+
`config_argument="--config"` — argclass adds the flag and applies the
47+
file's values as argument defaults via two-pass parsing (the flag is
48+
resolved first, then the real parser is built with the defaults in
49+
place, so even `--help` shows them):
50+
51+
<!--- name: test_config_argument --->
52+
```python
53+
import argclass
54+
from pathlib import Path
55+
from tempfile import NamedTemporaryFile
56+
57+
class Parser(argclass.Parser):
58+
host: str = "localhost"
59+
port: int = 8080
60+
61+
with NamedTemporaryFile(mode="w", suffix=".ini", delete=False) as f:
62+
f.write("[DEFAULT]\nhost = example.com\nport = 9000\n")
63+
config_path = f.name
64+
65+
parser = Parser(config_argument="--config")
66+
parser.parse_args(["--config", config_path, "--port", "1234"])
67+
68+
assert parser.host == "example.com" # default from the file
69+
assert parser.port == 1234 # CLI still wins
70+
71+
Path(config_path).unlink()
72+
```
73+
74+
Details:
75+
76+
- The priority chain extends naturally: declared defaults <
77+
`config_files` < `config_argument` file < env vars < CLI args.
78+
- The file format is the shared `config_parser_class` (INI by
79+
default; pass `JSONDefaultsParser` / `TOMLDefaultsParser` for other
80+
formats).
81+
- A required argument is satisfied by a value from the file.
82+
- Several aliases are accepted: `config_argument=("-c", "--config")`.
83+
- An explicitly passed path that does not exist or cannot be parsed
84+
raises `ConfigurationError` — unlike `config_files`, which is a
85+
lenient search list.
86+
- `parser.loaded_config_files` reports which files were applied, in
87+
priority order.
88+
- The flag is resolved by the parser whose `parse_args()` you call;
89+
put it before any subcommand on the command line.
90+
91+
This is different from `argclass.Config()`, which loads a file into
92+
an attribute as raw data without touching other arguments' defaults.
93+
4294
## Supported Formats
4395

4496
::::{grid} 3

docs/index.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ and seamless integration with configuration files and environment variables.
3535
- **Multiple config formats** - Load defaults from INI, JSON, or TOML configuration
3636
files with automatic type conversion. Each format has built-in support with
3737
no additional dependencies (TOML requires Python 3.11+ or `tomli` package).
38+
- **User-supplied config** - `config_argument="--config"` adds a CLI flag so
39+
the end user can point at a config file whose values become argument
40+
defaults at invocation time.
3841
- **Environment variables** - Read configuration from environment variables
3942
with optional prefix support for namespacing.
4043
- **Secret handling** - Built-in support for sensitive values that are masked

docs/pitfalls.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,7 @@ type mismatches raise `ConfigurationError`.
449449
| Malformed INI/JSON/TOML | `ConfigurationError` with file path |
450450
| Value doesn't match type | `ConfigurationError` with field and section |
451451
| Missing file | Silently ignored (unless `strict_config=True`) |
452+
| Missing/malformed `config_argument` file | Always `ConfigurationError` (the user asked for it explicitly) |
452453

453454
<!--- name: test_pitfall_config_ok --->
454455
```python

0 commit comments

Comments
 (0)