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 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