confargs 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- confargs/__init__.py +44 -0
- confargs/argfile.py +77 -0
- confargs/base.py +72 -0
- confargs/cli.py +167 -0
- confargs/coercion.py +129 -0
- confargs/demo.py +77 -0
- confargs/env_source.py +50 -0
- confargs/exceptions.py +62 -0
- confargs/help.py +79 -0
- confargs/namespace.py +69 -0
- confargs/options.py +261 -0
- confargs/processor.py +250 -0
- confargs/py.typed +0 -0
- confargs/toml_source.py +105 -0
- confargs-0.2.0.dist-info/METADATA +222 -0
- confargs-0.2.0.dist-info/RECORD +19 -0
- confargs-0.2.0.dist-info/WHEEL +4 -0
- confargs-0.2.0.dist-info/entry_points.txt +2 -0
- confargs-0.2.0.dist-info/licenses/LICENSE +21 -0
confargs/__init__.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""confargs: declarative CLI parsing that merges CLI, env vars and TOML config.
|
|
2
|
+
|
|
3
|
+
Declare options as methods on an :class:`ArgConfig` subclass, decorate them with
|
|
4
|
+
:func:`option`, then resolve everything with ``ConfigurationProcessor``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from confargs.argfile import read_argument_file, split_argument_file
|
|
10
|
+
from confargs.base import ArgConfig
|
|
11
|
+
from confargs.exceptions import (
|
|
12
|
+
MISSING,
|
|
13
|
+
ArgConfigError,
|
|
14
|
+
CliUsageError,
|
|
15
|
+
ConfigDiscoveryError,
|
|
16
|
+
Exit,
|
|
17
|
+
OptionDefinitionError,
|
|
18
|
+
OptionValueError,
|
|
19
|
+
)
|
|
20
|
+
from confargs.namespace import Namespace
|
|
21
|
+
from confargs.options import Option, collect_options, option, resolve_names
|
|
22
|
+
from confargs.processor import ConfigurationProcessor
|
|
23
|
+
|
|
24
|
+
__version__ = "0.2.0" # x-release-please-version
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"MISSING",
|
|
28
|
+
"ArgConfig",
|
|
29
|
+
"ArgConfigError",
|
|
30
|
+
"CliUsageError",
|
|
31
|
+
"ConfigDiscoveryError",
|
|
32
|
+
"ConfigurationProcessor",
|
|
33
|
+
"Exit",
|
|
34
|
+
"Namespace",
|
|
35
|
+
"Option",
|
|
36
|
+
"OptionDefinitionError",
|
|
37
|
+
"OptionValueError",
|
|
38
|
+
"__version__",
|
|
39
|
+
"collect_options",
|
|
40
|
+
"option",
|
|
41
|
+
"read_argument_file",
|
|
42
|
+
"resolve_names",
|
|
43
|
+
"split_argument_file",
|
|
44
|
+
]
|
confargs/argfile.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Helpers for reading *argument files* — text files that contain more options.
|
|
2
|
+
|
|
3
|
+
This mirrors the well-known Robot Framework ``--argumentfile`` format so that an
|
|
4
|
+
eager option (see :func:`confargs.option`) can expand a file into extra CLI
|
|
5
|
+
tokens:
|
|
6
|
+
|
|
7
|
+
* each line is stripped of surrounding whitespace,
|
|
8
|
+
* blank lines and ``#`` comment lines are ignored,
|
|
9
|
+
* a line starting with ``-`` is an option; it is split into a name and value on
|
|
10
|
+
the first space or ``=`` (whichever comes first), and
|
|
11
|
+
* any other non-empty line is passed through as a positional token.
|
|
12
|
+
|
|
13
|
+
Only the parsing is provided here; the decision to inject the resulting tokens
|
|
14
|
+
is made by the eager option's method, which returns them to the processor.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import TYPE_CHECKING
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _option_separator(line: str) -> str | None:
|
|
26
|
+
"""Return the separator (space or ``=``) between an option and its value."""
|
|
27
|
+
if " " not in line and "=" not in line:
|
|
28
|
+
return None
|
|
29
|
+
if "=" not in line:
|
|
30
|
+
return " "
|
|
31
|
+
if " " not in line:
|
|
32
|
+
return "="
|
|
33
|
+
return " " if line.index(" ") < line.index("=") else "="
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _split_option(line: str) -> list[str]:
|
|
37
|
+
separator = _option_separator(line)
|
|
38
|
+
if separator is None:
|
|
39
|
+
return [line]
|
|
40
|
+
name, value = line.split(separator, 1)
|
|
41
|
+
if separator == " ":
|
|
42
|
+
value = value.strip()
|
|
43
|
+
return [name, value]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def split_argument_file(text: str) -> list[str]:
|
|
47
|
+
"""Tokenize the *contents* of an argument file into a list of argv tokens.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
text: The full text of an argument file.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
The tokens to splice into ``argv``.
|
|
54
|
+
"""
|
|
55
|
+
tokens: list[str] = []
|
|
56
|
+
for raw_line in text.splitlines():
|
|
57
|
+
line = raw_line.strip()
|
|
58
|
+
if line.startswith("-"):
|
|
59
|
+
tokens.extend(_split_option(line))
|
|
60
|
+
elif line and not line.startswith("#"):
|
|
61
|
+
tokens.append(line)
|
|
62
|
+
return tokens
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def read_argument_file(path: str | Path, *, encoding: str = "utf-8") -> list[str]:
|
|
66
|
+
"""Read an argument file from disk and tokenize it.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
path: Path to the argument file.
|
|
70
|
+
encoding: Text encoding used to read the file.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
The tokens to splice into ``argv``.
|
|
74
|
+
"""
|
|
75
|
+
from pathlib import Path as _Path
|
|
76
|
+
|
|
77
|
+
return split_argument_file(_Path(path).read_text(encoding=encoding))
|
confargs/base.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""The :class:`ArgConfig` base class that tool authors subclass."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from confargs.exceptions import Exit
|
|
6
|
+
from confargs.options import option
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ArgConfig:
|
|
10
|
+
"""Base class for a tool's configuration.
|
|
11
|
+
|
|
12
|
+
Subclass this and declare options as methods decorated with
|
|
13
|
+
:func:`confargs.option`. Class attributes configure discovery and naming:
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
name: The tool name. Used for the default TOML section
|
|
17
|
+
(``[tool.<name>]``) and, when ``auto_env_vars`` is enabled, for the
|
|
18
|
+
environment variable prefix.
|
|
19
|
+
config_names: File names to look for when discovering TOML config,
|
|
20
|
+
in priority order.
|
|
21
|
+
default_config_section: Dotted path of the TOML table to read
|
|
22
|
+
(e.g. ``"tool.mytool"``). When unset, ``tool.<name>`` is used.
|
|
23
|
+
auto_env_vars: When true, every option (that is not ``cli_only``) gets
|
|
24
|
+
an implicit environment variable named ``<NAME>_<OPTION>``.
|
|
25
|
+
strict_config: When true (the default), unknown keys or ``cli_only``
|
|
26
|
+
options found in a TOML config section raise an error instead of
|
|
27
|
+
being ignored.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
name: str | None = None
|
|
31
|
+
config_names: list[str] = ["pyproject.toml"] # noqa: RUF012 - documented, per-subclass override
|
|
32
|
+
default_config_section: str | None = None
|
|
33
|
+
auto_env_vars: bool = False
|
|
34
|
+
strict_config: bool = True
|
|
35
|
+
|
|
36
|
+
@option(names="--help/-h", cli_only=True)
|
|
37
|
+
def help(self, value: bool = False) -> bool:
|
|
38
|
+
"""Show this help message and exit."""
|
|
39
|
+
if value:
|
|
40
|
+
from confargs.help import format_help
|
|
41
|
+
|
|
42
|
+
print(format_help(self))
|
|
43
|
+
raise Exit(0)
|
|
44
|
+
return value
|
|
45
|
+
|
|
46
|
+
@option(names="--config", cli_only=True)
|
|
47
|
+
def config(self, value: str | None = None) -> str | None:
|
|
48
|
+
"""Read configuration from this file only, skipping discovery."""
|
|
49
|
+
return value
|
|
50
|
+
|
|
51
|
+
@option(names="--no-config", cli_only=True)
|
|
52
|
+
def no_config(self, value: bool = False) -> bool:
|
|
53
|
+
"""Do not read any configuration file."""
|
|
54
|
+
return value
|
|
55
|
+
|
|
56
|
+
@option(names="--ignore-git", cli_only=True)
|
|
57
|
+
def ignore_git(self, value: bool = False) -> bool:
|
|
58
|
+
"""Keep searching for config files above the project's .git directory."""
|
|
59
|
+
return value
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def config_section(self) -> tuple[str, ...]:
|
|
63
|
+
"""The TOML table path to read configuration from."""
|
|
64
|
+
if self.default_config_section:
|
|
65
|
+
return tuple(self.default_config_section.split("."))
|
|
66
|
+
base = self.name or type(self).__name__.lower()
|
|
67
|
+
return ("tool", base)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def tool_name(self) -> str:
|
|
71
|
+
"""A non-optional tool name, falling back to the class name."""
|
|
72
|
+
return self.name or type(self).__name__.lower()
|
confargs/cli.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""A minimal command-line tokenizer.
|
|
2
|
+
|
|
3
|
+
This is deliberately tiny: it only splits ``argv`` into raw per-option values.
|
|
4
|
+
It does **no** type conversion, defaulting or validation — those happen later so
|
|
5
|
+
that the same coercion/validation path is shared by every source (CLI, env,
|
|
6
|
+
TOML). Supported forms:
|
|
7
|
+
|
|
8
|
+
* ``--long value`` and ``--long=value``
|
|
9
|
+
* ``-s value``, ``-svalue`` (attached) and combined flags ``-abc``
|
|
10
|
+
* boolean flags: ``--flag`` / ``-f`` (and ``--flag=false``)
|
|
11
|
+
* boolean negation: ``--no-flag`` sets a boolean option to ``False``
|
|
12
|
+
* ``--`` terminates option parsing; the rest are positionals
|
|
13
|
+
* a lone ``-`` is treated as a positional (stdin convention)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import TYPE_CHECKING, Any
|
|
20
|
+
|
|
21
|
+
from confargs.exceptions import CliUsageError
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from collections.abc import Sequence
|
|
25
|
+
|
|
26
|
+
from confargs.options import NameTable
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class CliResult:
|
|
31
|
+
"""The outcome of tokenizing ``argv``."""
|
|
32
|
+
|
|
33
|
+
values: dict[str, Any] = field(default_factory=dict)
|
|
34
|
+
positionals: list[str] = field(default_factory=list)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _store(result: CliResult, attr: str, value: Any, list_opts: set[str]) -> None:
|
|
38
|
+
if attr in list_opts:
|
|
39
|
+
result.values.setdefault(attr, []).append(value)
|
|
40
|
+
else:
|
|
41
|
+
result.values[attr] = value
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def negation_name(long_name: str) -> str:
|
|
45
|
+
"""Return the ``--no-`` negation form of a long option name."""
|
|
46
|
+
return f"--no-{long_name[2:]}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _negated_flag_attr(name: str, table: NameTable, flags: set[str]) -> str | None:
|
|
50
|
+
"""If ``name`` is a ``--no-<flag>`` negation of a known flag, return its attr."""
|
|
51
|
+
if not name.startswith("--no-"):
|
|
52
|
+
return None
|
|
53
|
+
base = f"--{name[len('--no-') :]}"
|
|
54
|
+
attr = table.long_to_attr.get(base)
|
|
55
|
+
if attr is not None and attr in flags:
|
|
56
|
+
return attr
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def parse_cli(
|
|
61
|
+
argv: Sequence[str],
|
|
62
|
+
table: NameTable,
|
|
63
|
+
flags: set[str],
|
|
64
|
+
list_opts: set[str],
|
|
65
|
+
) -> CliResult:
|
|
66
|
+
"""Tokenize ``argv`` into raw per-option values.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
argv: Arguments to parse (without the program name).
|
|
70
|
+
table: Resolved name-to-attribute mapping.
|
|
71
|
+
flags: Attribute names of boolean flag options.
|
|
72
|
+
list_opts: Attribute names of repeatable (list) options.
|
|
73
|
+
"""
|
|
74
|
+
result = CliResult()
|
|
75
|
+
args = list(argv)
|
|
76
|
+
index = 0
|
|
77
|
+
positional_only = False
|
|
78
|
+
|
|
79
|
+
while index < len(args):
|
|
80
|
+
token = args[index]
|
|
81
|
+
index += 1
|
|
82
|
+
|
|
83
|
+
if positional_only:
|
|
84
|
+
result.positionals.append(token)
|
|
85
|
+
continue
|
|
86
|
+
if token == "--":
|
|
87
|
+
positional_only = True
|
|
88
|
+
continue
|
|
89
|
+
if token == "-" or not token.startswith("-"):
|
|
90
|
+
result.positionals.append(token)
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
if token.startswith("--"):
|
|
94
|
+
index = _handle_long(token, args, index, result, table, flags, list_opts)
|
|
95
|
+
else:
|
|
96
|
+
index = _handle_short(token, args, index, result, table, flags, list_opts)
|
|
97
|
+
|
|
98
|
+
return result
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _consume_value(name: str, args: list[str], index: int, table: NameTable) -> tuple[str, int]:
|
|
102
|
+
if index >= len(args) or table.attr_for(args[index]) is not None or args[index] == "--":
|
|
103
|
+
raise CliUsageError(f"option {name!r} expects a value")
|
|
104
|
+
return args[index], index + 1
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _handle_long(
|
|
108
|
+
token: str,
|
|
109
|
+
args: list[str],
|
|
110
|
+
index: int,
|
|
111
|
+
result: CliResult,
|
|
112
|
+
table: NameTable,
|
|
113
|
+
flags: set[str],
|
|
114
|
+
list_opts: set[str],
|
|
115
|
+
) -> int:
|
|
116
|
+
name, sep, inline = token.partition("=")
|
|
117
|
+
attr = table.long_to_attr.get(name)
|
|
118
|
+
if attr is None:
|
|
119
|
+
negated = _negated_flag_attr(name, table, flags)
|
|
120
|
+
if negated is not None:
|
|
121
|
+
if sep:
|
|
122
|
+
raise CliUsageError(f"option {name!r} does not take a value")
|
|
123
|
+
_store(result, negated, False, list_opts)
|
|
124
|
+
return index
|
|
125
|
+
raise CliUsageError(f"unknown option {name!r}")
|
|
126
|
+
|
|
127
|
+
if attr in flags:
|
|
128
|
+
_store(result, attr, inline if sep else True, list_opts)
|
|
129
|
+
return index
|
|
130
|
+
|
|
131
|
+
if sep:
|
|
132
|
+
_store(result, attr, inline, list_opts)
|
|
133
|
+
return index
|
|
134
|
+
value, index = _consume_value(name, args, index, table)
|
|
135
|
+
_store(result, attr, value, list_opts)
|
|
136
|
+
return index
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _handle_short(
|
|
140
|
+
token: str,
|
|
141
|
+
args: list[str],
|
|
142
|
+
index: int,
|
|
143
|
+
result: CliResult,
|
|
144
|
+
table: NameTable,
|
|
145
|
+
flags: set[str],
|
|
146
|
+
list_opts: set[str],
|
|
147
|
+
) -> int:
|
|
148
|
+
body = token[1:]
|
|
149
|
+
position = 0
|
|
150
|
+
while position < len(body):
|
|
151
|
+
name = f"-{body[position]}"
|
|
152
|
+
attr = table.short_to_attr.get(name)
|
|
153
|
+
if attr is None:
|
|
154
|
+
raise CliUsageError(f"unknown option {name!r}")
|
|
155
|
+
if attr in flags:
|
|
156
|
+
_store(result, attr, True, list_opts)
|
|
157
|
+
position += 1
|
|
158
|
+
continue
|
|
159
|
+
# Value option: the remainder of the cluster is the value, else the next token.
|
|
160
|
+
attached = body[position + 1 :]
|
|
161
|
+
if attached:
|
|
162
|
+
_store(result, attr, attached, list_opts)
|
|
163
|
+
else:
|
|
164
|
+
value, index = _consume_value(name, args, index, table)
|
|
165
|
+
_store(result, attr, value, list_opts)
|
|
166
|
+
return index
|
|
167
|
+
return index
|
confargs/coercion.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Type resolution and coercion of raw source values.
|
|
2
|
+
|
|
3
|
+
confargs only performs *basic* coercion so that the value handed to a user's
|
|
4
|
+
option method matches the type they annotated. All domain validation and
|
|
5
|
+
parsing is left to the method itself.
|
|
6
|
+
|
|
7
|
+
Values arrive in two shapes:
|
|
8
|
+
|
|
9
|
+
* **strings** from the command line and environment variables, and
|
|
10
|
+
* **already-typed** values from TOML (``int``, ``float``, ``bool``, ``str``,
|
|
11
|
+
``list``).
|
|
12
|
+
|
|
13
|
+
:func:`coerce_value` normalises both into the option's declared type.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import types
|
|
19
|
+
import typing
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import Any, Union, get_args, get_origin
|
|
22
|
+
|
|
23
|
+
from confargs.exceptions import MISSING, OptionValueError
|
|
24
|
+
|
|
25
|
+
if typing.TYPE_CHECKING:
|
|
26
|
+
from confargs.options import Option
|
|
27
|
+
|
|
28
|
+
_TRUE = {"1", "true", "yes", "on", "y", "t"}
|
|
29
|
+
_FALSE = {"0", "false", "no", "off", "n", "f"}
|
|
30
|
+
|
|
31
|
+
_UNION_ORIGINS = {Union, types.UnionType}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class ValueType:
|
|
36
|
+
"""A simplified description of an option's declared value type."""
|
|
37
|
+
|
|
38
|
+
base: type
|
|
39
|
+
is_list: bool = False
|
|
40
|
+
allows_none: bool = False
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def is_flag(self) -> bool:
|
|
44
|
+
"""A boolean, non-list option — a command line flag."""
|
|
45
|
+
return self.base is bool and not self.is_list
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def resolve_value_type(option: Option) -> ValueType:
|
|
49
|
+
"""Inspect an option method and describe the type its value expects."""
|
|
50
|
+
annotation = option.raw_annotation
|
|
51
|
+
if annotation is MISSING:
|
|
52
|
+
return ValueType(base=str)
|
|
53
|
+
|
|
54
|
+
hints = typing.get_type_hints(option.func)
|
|
55
|
+
hint = hints.get(option.value_parameter.name, str)
|
|
56
|
+
return _analyse(hint)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _analyse(hint: Any) -> ValueType:
|
|
60
|
+
allows_none = False
|
|
61
|
+
origin = get_origin(hint)
|
|
62
|
+
|
|
63
|
+
if origin in _UNION_ORIGINS:
|
|
64
|
+
args = [arg for arg in get_args(hint) if arg is not type(None)]
|
|
65
|
+
allows_none = len(args) != len(get_args(hint))
|
|
66
|
+
# Take the first concrete member as the representative type.
|
|
67
|
+
hint = args[0] if args else str
|
|
68
|
+
origin = get_origin(hint)
|
|
69
|
+
|
|
70
|
+
if origin in (list, set, tuple):
|
|
71
|
+
elem_args = get_args(hint)
|
|
72
|
+
element = elem_args[0] if elem_args else str
|
|
73
|
+
base = element if isinstance(element, type) else str
|
|
74
|
+
return ValueType(base=base, is_list=True, allows_none=allows_none)
|
|
75
|
+
|
|
76
|
+
base = hint if isinstance(hint, type) else str
|
|
77
|
+
return ValueType(base=base, is_list=False, allows_none=allows_none)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def parse_bool(raw: str) -> bool:
|
|
81
|
+
"""Parse a boolean from a string, accepting common spellings."""
|
|
82
|
+
lowered = raw.strip().lower()
|
|
83
|
+
if lowered in _TRUE:
|
|
84
|
+
return True
|
|
85
|
+
if lowered in _FALSE:
|
|
86
|
+
return False
|
|
87
|
+
raise OptionValueError(f"cannot interpret {raw!r} as a boolean")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _coerce_scalar(raw: Any, base: type) -> Any:
|
|
91
|
+
if base is bool:
|
|
92
|
+
if isinstance(raw, bool):
|
|
93
|
+
return raw
|
|
94
|
+
return parse_bool(str(raw))
|
|
95
|
+
if base is str:
|
|
96
|
+
return raw if isinstance(raw, str) else str(raw)
|
|
97
|
+
if base in (int, float):
|
|
98
|
+
try:
|
|
99
|
+
return base(raw)
|
|
100
|
+
except (TypeError, ValueError) as exc:
|
|
101
|
+
raise OptionValueError(f"cannot interpret {raw!r} as {base.__name__}") from exc
|
|
102
|
+
# Unknown/custom base type: pass the raw value through untouched.
|
|
103
|
+
return raw
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _as_list(raw: Any) -> list[Any]:
|
|
107
|
+
if isinstance(raw, (list, tuple, set)):
|
|
108
|
+
return list(raw)
|
|
109
|
+
if isinstance(raw, str):
|
|
110
|
+
return [item.strip() for item in raw.split(",") if item.strip()]
|
|
111
|
+
return [raw]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def coerce_value(raw: Any, value_type: ValueType) -> Any:
|
|
115
|
+
"""Coerce a raw source value into the option's declared type.
|
|
116
|
+
|
|
117
|
+
``None`` is passed through when the option allows it. List options accept
|
|
118
|
+
native sequences (TOML arrays, repeated CLI flags) or comma-separated
|
|
119
|
+
strings (environment variables).
|
|
120
|
+
"""
|
|
121
|
+
if raw is None:
|
|
122
|
+
if value_type.allows_none:
|
|
123
|
+
return None
|
|
124
|
+
raise OptionValueError("value may not be null")
|
|
125
|
+
|
|
126
|
+
if value_type.is_list:
|
|
127
|
+
return [_coerce_scalar(item, value_type.base) for item in _as_list(raw)]
|
|
128
|
+
|
|
129
|
+
return _coerce_scalar(raw, value_type.base)
|
confargs/demo.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""A small, runnable example tool built with confargs.
|
|
2
|
+
|
|
3
|
+
This module doubles as the ``confargs-demo`` console script (see
|
|
4
|
+
``[project.scripts]`` in ``pyproject.toml``). Try it once installed::
|
|
5
|
+
|
|
6
|
+
confargs-demo --console quiet --retries 5
|
|
7
|
+
confargs-demo --log NONE
|
|
8
|
+
MYTOOL_CONSOLE=dotted confargs-demo
|
|
9
|
+
confargs-demo --help
|
|
10
|
+
|
|
11
|
+
Or without installing::
|
|
12
|
+
|
|
13
|
+
uv run confargs-demo --console quiet
|
|
14
|
+
|
|
15
|
+
Configuration is also read from ``[tool.mytool]`` in a discovered
|
|
16
|
+
``pyproject.toml`` and from ``MYTOOL_*`` environment variables.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import confargs
|
|
22
|
+
from confargs import ArgConfig
|
|
23
|
+
|
|
24
|
+
__all__ = ["MyArgs", "main"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class MyArgs(ArgConfig):
|
|
28
|
+
"""mytool - a tiny demo CLI built with confargs.
|
|
29
|
+
|
|
30
|
+
Shows how command line arguments, environment variables and TOML config are
|
|
31
|
+
merged into one configuration object.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
name = "mytool"
|
|
35
|
+
auto_env_vars = True
|
|
36
|
+
|
|
37
|
+
@confargs.option
|
|
38
|
+
def log(self, value: str | None = "log.html") -> str | None:
|
|
39
|
+
"""HTML log file. Disable with the special value 'NONE'."""
|
|
40
|
+
if value == "NONE":
|
|
41
|
+
return None
|
|
42
|
+
return value
|
|
43
|
+
|
|
44
|
+
@confargs.option(names="--console/-c")
|
|
45
|
+
def console(self, value: str = "verbose") -> str:
|
|
46
|
+
"""Console output mode: verbose, dotted, quiet or none."""
|
|
47
|
+
choices = ["verbose", "dotted", "quiet", "none"]
|
|
48
|
+
if value not in choices:
|
|
49
|
+
raise confargs.OptionValueError(f"console must be one of {choices}, got {value!r}")
|
|
50
|
+
return value
|
|
51
|
+
|
|
52
|
+
@confargs.option
|
|
53
|
+
def retries(self, value: int = 3) -> int:
|
|
54
|
+
"""Number of retries on failure."""
|
|
55
|
+
if value < 0:
|
|
56
|
+
raise confargs.OptionValueError("retries must be >= 0")
|
|
57
|
+
return value
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def main(argv: list[str] | None = None) -> int:
|
|
61
|
+
"""Entry point for the ``confargs-demo`` console script."""
|
|
62
|
+
try:
|
|
63
|
+
config = confargs.ConfigurationProcessor(MyArgs, argv=argv).process()
|
|
64
|
+
except confargs.Exit as exit_signal:
|
|
65
|
+
return exit_signal.code
|
|
66
|
+
except confargs.ArgConfigError as error:
|
|
67
|
+
print(f"error: {error}")
|
|
68
|
+
return 2
|
|
69
|
+
|
|
70
|
+
print("log =", config.log)
|
|
71
|
+
print("console =", config.console)
|
|
72
|
+
print("retries =", config.retries)
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
if __name__ == "__main__":
|
|
77
|
+
raise SystemExit(main())
|
confargs/env_source.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Environment-variable configuration source.
|
|
2
|
+
|
|
3
|
+
Each option can read from an environment variable in one of two ways:
|
|
4
|
+
|
|
5
|
+
* explicitly, via ``@option(envvar="MY_TOOL_LOG")``, or
|
|
6
|
+
* implicitly, when the config class sets ``auto_env_vars = True``, in which case
|
|
7
|
+
every non-``cli_only`` option gets an implicit variable named
|
|
8
|
+
``<TOOL_NAME>_<OPTION>`` (upper-cased).
|
|
9
|
+
|
|
10
|
+
An explicit ``envvar`` always wins over the auto-generated name.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from collections.abc import Mapping
|
|
19
|
+
|
|
20
|
+
from confargs.options import Option
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def env_var_name(
|
|
24
|
+
option: Option,
|
|
25
|
+
tool_name: str,
|
|
26
|
+
*,
|
|
27
|
+
auto_env_vars: bool,
|
|
28
|
+
) -> str | None:
|
|
29
|
+
"""Return the environment variable name for ``option``, or ``None``."""
|
|
30
|
+
if option.envvar:
|
|
31
|
+
return option.envvar
|
|
32
|
+
if auto_env_vars and not option.cli_only:
|
|
33
|
+
return f"{tool_name.upper()}_{option.attr_name.upper()}"
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def collect_env_values(
|
|
38
|
+
options: Mapping[str, Option],
|
|
39
|
+
tool_name: str,
|
|
40
|
+
*,
|
|
41
|
+
auto_env_vars: bool,
|
|
42
|
+
environ: Mapping[str, str],
|
|
43
|
+
) -> dict[str, str]:
|
|
44
|
+
"""Collect raw option values present in ``environ``."""
|
|
45
|
+
values: dict[str, str] = {}
|
|
46
|
+
for attr, option in options.items():
|
|
47
|
+
name = env_var_name(option, tool_name, auto_env_vars=auto_env_vars)
|
|
48
|
+
if name is not None and name in environ:
|
|
49
|
+
values[attr] = environ[name]
|
|
50
|
+
return values
|
confargs/exceptions.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Exceptions and sentinels used across confargs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class _Missing:
|
|
9
|
+
"""Sentinel for "no value supplied" (distinct from ``None``)."""
|
|
10
|
+
|
|
11
|
+
_instance: _Missing | None = None
|
|
12
|
+
|
|
13
|
+
def __new__(cls) -> _Missing:
|
|
14
|
+
if cls._instance is None:
|
|
15
|
+
cls._instance = super().__new__(cls)
|
|
16
|
+
return cls._instance
|
|
17
|
+
|
|
18
|
+
def __repr__(self) -> str:
|
|
19
|
+
return "MISSING"
|
|
20
|
+
|
|
21
|
+
def __bool__(self) -> bool:
|
|
22
|
+
return False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
MISSING: Any = _Missing()
|
|
26
|
+
"""Singleton sentinel meaning "this source did not provide a value"."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ArgConfigError(Exception):
|
|
30
|
+
"""Base class for every error raised by confargs."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class OptionDefinitionError(ArgConfigError):
|
|
34
|
+
"""Raised when an option/config class is defined incorrectly.
|
|
35
|
+
|
|
36
|
+
This signals a *programming* error in the tool that uses confargs (for
|
|
37
|
+
example, two options claiming the same name), not bad user input.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class OptionValueError(ArgConfigError):
|
|
42
|
+
"""Raised when a supplied option value is invalid.
|
|
43
|
+
|
|
44
|
+
Tool authors raise this from their option methods to reject a value; the
|
|
45
|
+
processor turns it into a friendly command-line error.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ConfigDiscoveryError(ArgConfigError):
|
|
50
|
+
"""Raised when configuration files cannot be read or parsed."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class CliUsageError(ArgConfigError):
|
|
54
|
+
"""Raised for malformed command-line input (unknown or incomplete options)."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Exit(ArgConfigError):
|
|
58
|
+
"""Raised to stop processing and exit (e.g. after printing ``--help``)."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, code: int = 0) -> None:
|
|
61
|
+
super().__init__(f"exit with code {code}")
|
|
62
|
+
self.code = code
|