argbuilder 0.1.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.
- argbuilder/__init__.py +75 -0
- argbuilder/_arg.py +264 -0
- argbuilder/_build.py +431 -0
- argbuilder/_command.py +222 -0
- argbuilder/_derive.py +702 -0
- argbuilder/_error.py +242 -0
- argbuilder/_help.py +157 -0
- argbuilder/_invariant.py +46 -0
- argbuilder/_matches.py +180 -0
- argbuilder/_parser.py +438 -0
- argbuilder/_spec.py +91 -0
- argbuilder/_style.py +103 -0
- argbuilder/_value_parser.py +208 -0
- argbuilder/py.typed +0 -0
- argbuilder-0.1.0.dist-info/METADATA +309 -0
- argbuilder-0.1.0.dist-info/RECORD +18 -0
- argbuilder-0.1.0.dist-info/WHEEL +4 -0
- argbuilder-0.1.0.dist-info/licenses/LICENSE +21 -0
argbuilder/__init__.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Typed command-line parsing from immutable builders.
|
|
2
|
+
|
|
3
|
+
Bugs panic, user mistakes return values:
|
|
4
|
+
|
|
5
|
+
- A broken *definition* (duplicate `-v`, a default the parser rejects,
|
|
6
|
+
`get_one` with the wrong type) raises `AssertionError` at the exact line.
|
|
7
|
+
- A bad *command line* is an `Error` value from `try_get_matches_from`, or a
|
|
8
|
+
printed message plus exit code 2 from `get_matches`.
|
|
9
|
+
|
|
10
|
+
`ArgAction` and `ValueSource` are `Literal` strings: `.action("count")`.
|
|
11
|
+
`ErrorKind` is a union of frozen dataclasses that carry what went wrong:
|
|
12
|
+
`match error.kind: case InvalidValue(argument=a, value=v): ...`.
|
|
13
|
+
|
|
14
|
+
Or derive the command from a class, as with clap's derive API:
|
|
15
|
+
`class Cli(Parser): port: int = arg(short=True, default=22)`, then `Cli.parse()`.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from argbuilder._arg import Arg, ArgGroup
|
|
21
|
+
from argbuilder._command import Command
|
|
22
|
+
from argbuilder._derive import Args, FieldAction, Parser, arg
|
|
23
|
+
from argbuilder._error import (
|
|
24
|
+
ArgumentConflict,
|
|
25
|
+
DisplayHelp,
|
|
26
|
+
DisplayHelpOnMissingArgumentOrSubcommand,
|
|
27
|
+
DisplayVersion,
|
|
28
|
+
Error,
|
|
29
|
+
ErrorKind,
|
|
30
|
+
InvalidSubcommand,
|
|
31
|
+
InvalidValue,
|
|
32
|
+
MissingRequiredArgument,
|
|
33
|
+
MissingSubcommand,
|
|
34
|
+
ParseFailure,
|
|
35
|
+
TooFewValues,
|
|
36
|
+
TooManyValues,
|
|
37
|
+
UnknownArgument,
|
|
38
|
+
ValueValidation,
|
|
39
|
+
)
|
|
40
|
+
from argbuilder._matches import ArgMatches, ValueSource
|
|
41
|
+
from argbuilder._spec import ArgAction
|
|
42
|
+
from argbuilder._style import Style
|
|
43
|
+
from argbuilder._value_parser import Invalid, ValueParser, ValueParserLike
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"Arg",
|
|
47
|
+
"ArgAction",
|
|
48
|
+
"ArgGroup",
|
|
49
|
+
"ArgMatches",
|
|
50
|
+
"Args",
|
|
51
|
+
"ArgumentConflict",
|
|
52
|
+
"Command",
|
|
53
|
+
"DisplayHelp",
|
|
54
|
+
"DisplayHelpOnMissingArgumentOrSubcommand",
|
|
55
|
+
"DisplayVersion",
|
|
56
|
+
"Error",
|
|
57
|
+
"ErrorKind",
|
|
58
|
+
"FieldAction",
|
|
59
|
+
"Invalid",
|
|
60
|
+
"InvalidSubcommand",
|
|
61
|
+
"InvalidValue",
|
|
62
|
+
"MissingRequiredArgument",
|
|
63
|
+
"MissingSubcommand",
|
|
64
|
+
"ParseFailure",
|
|
65
|
+
"Parser",
|
|
66
|
+
"Style",
|
|
67
|
+
"TooFewValues",
|
|
68
|
+
"TooManyValues",
|
|
69
|
+
"UnknownArgument",
|
|
70
|
+
"ValueParser",
|
|
71
|
+
"ValueParserLike",
|
|
72
|
+
"ValueSource",
|
|
73
|
+
"ValueValidation",
|
|
74
|
+
"arg",
|
|
75
|
+
]
|
argbuilder/_arg.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""`Arg` and `ArgGroup`: fluent, immutable builders in the style of clap's builder API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
from typing import TYPE_CHECKING, Any, overload
|
|
7
|
+
|
|
8
|
+
from argbuilder._invariant import bug, invariant
|
|
9
|
+
from argbuilder._spec import ArgAction, ArgSpec, GroupSpec, check_action
|
|
10
|
+
from argbuilder._value_parser import ValueParserLike, into_value_parser
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from collections.abc import Iterable
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _check_bool(owner: str, method: str, value: object) -> bool:
|
|
17
|
+
invariant(isinstance(value, bool), f"{owner}.{method}() takes a bool, got {value!r}")
|
|
18
|
+
return bool(value)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _check_id(kind: str, value: object) -> str:
|
|
22
|
+
invariant(
|
|
23
|
+
isinstance(value, str) and value != "" and not value.isspace(),
|
|
24
|
+
f"{kind} id must be a non-empty str, got {value!r}",
|
|
25
|
+
)
|
|
26
|
+
return str(value)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Arg:
|
|
30
|
+
"""One argument, built fluently: `Arg("port").short("p").long("port")`.
|
|
31
|
+
|
|
32
|
+
With no `short()`/`long()` it is positional. Every method returns a new
|
|
33
|
+
`Arg`, so a definition can be shared between commands. Mistakes local to
|
|
34
|
+
one argument panic right here, at the builder call. Mistakes that span
|
|
35
|
+
arguments (duplicate shorts, dangling `requires`) panic when the command
|
|
36
|
+
is built.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
__slots__ = ("_spec",)
|
|
40
|
+
_spec: ArgSpec
|
|
41
|
+
|
|
42
|
+
def __init__(self, id: str) -> None:
|
|
43
|
+
self._spec = ArgSpec(id=_check_id("Arg", id))
|
|
44
|
+
|
|
45
|
+
def __repr__(self) -> str:
|
|
46
|
+
return f"Arg({self._spec.id!r})"
|
|
47
|
+
|
|
48
|
+
def get_id(self) -> str:
|
|
49
|
+
return self._spec.id
|
|
50
|
+
|
|
51
|
+
def _with(self, **changes: Any) -> Arg:
|
|
52
|
+
new: Arg = Arg.__new__(Arg)
|
|
53
|
+
new._spec = dataclasses.replace(self._spec, **changes)
|
|
54
|
+
return new
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def _owner(self) -> str:
|
|
58
|
+
return f"Arg({self._spec.id!r})"
|
|
59
|
+
|
|
60
|
+
def short(self, char: str) -> Arg:
|
|
61
|
+
"""`-c`. A single character other than `-`, `=` or whitespace."""
|
|
62
|
+
return self._with(short=_check_short(self._owner, "short", char))
|
|
63
|
+
|
|
64
|
+
def long(self, name: str) -> Arg:
|
|
65
|
+
"""`--name`. Pass it without the leading dashes."""
|
|
66
|
+
return self._with(long=_check_long(self._owner, "long", name))
|
|
67
|
+
|
|
68
|
+
def alias(self, name: str) -> Arg:
|
|
69
|
+
"""A hidden extra `--name`: it parses, but help and suggestions leave it out."""
|
|
70
|
+
name = _check_long(self._owner, "alias", name)
|
|
71
|
+
return self._with(aliases=(*self._spec.aliases, name))
|
|
72
|
+
|
|
73
|
+
def visible_alias(self, name: str) -> Arg:
|
|
74
|
+
"""An extra `--name`, listed in help as `[aliases: --name]`."""
|
|
75
|
+
name = _check_long(self._owner, "visible_alias", name)
|
|
76
|
+
return self._with(visible_aliases=(*self._spec.visible_aliases, name))
|
|
77
|
+
|
|
78
|
+
def short_alias(self, char: str) -> Arg:
|
|
79
|
+
"""A hidden extra `-c`."""
|
|
80
|
+
char = _check_short(self._owner, "short_alias", char)
|
|
81
|
+
return self._with(short_aliases=(*self._spec.short_aliases, char))
|
|
82
|
+
|
|
83
|
+
def visible_short_alias(self, char: str) -> Arg:
|
|
84
|
+
"""An extra `-c`, listed in help as `[short aliases: -c]`."""
|
|
85
|
+
char = _check_short(self._owner, "visible_short_alias", char)
|
|
86
|
+
return self._with(visible_short_aliases=(*self._spec.visible_short_aliases, char))
|
|
87
|
+
|
|
88
|
+
def global_(self, yes: bool) -> Arg:
|
|
89
|
+
"""Also accept this option in every subcommand below, and read it at any level.
|
|
90
|
+
|
|
91
|
+
`git -v push` and `git push -v` both count. The whole command line
|
|
92
|
+
counts as one occurrence list: `count` and `append` add up across
|
|
93
|
+
levels, and a `set` option given at two levels is used twice.
|
|
94
|
+
"""
|
|
95
|
+
return self._with(global_=_check_bool(self._owner, "global_", yes))
|
|
96
|
+
|
|
97
|
+
def help(self, text: str) -> Arg:
|
|
98
|
+
invariant(isinstance(text, str), f"{self._owner}.help() takes a str, got {text!r}")
|
|
99
|
+
return self._with(help=text)
|
|
100
|
+
|
|
101
|
+
def value_name(self, name: str) -> Arg:
|
|
102
|
+
"""Placeholder in help and errors: `--port <PORT>`. Defaults to the id, uppercased."""
|
|
103
|
+
invariant(
|
|
104
|
+
isinstance(name, str) and name != "",
|
|
105
|
+
f"{self._owner}.value_name() takes a non-empty str, got {name!r}",
|
|
106
|
+
)
|
|
107
|
+
return self._with(value_name=name)
|
|
108
|
+
|
|
109
|
+
def required(self, yes: bool) -> Arg:
|
|
110
|
+
return self._with(required=_check_bool(self._owner, "required", yes))
|
|
111
|
+
|
|
112
|
+
def action(self, action: ArgAction) -> Arg:
|
|
113
|
+
"""Defaults to `"set"`: an argument takes a value unless told otherwise.
|
|
114
|
+
|
|
115
|
+
`Arg("v").short("v").action("count")`
|
|
116
|
+
"""
|
|
117
|
+
action = check_action(self._owner, action)
|
|
118
|
+
return self._with(action=action)
|
|
119
|
+
|
|
120
|
+
def value_parser(self, parser: ValueParserLike) -> Arg:
|
|
121
|
+
"""How raw strings become typed values.
|
|
122
|
+
|
|
123
|
+
Accepts a `ValueParser`, a callable (`int`, `float`, `Path`, ...), a
|
|
124
|
+
`range`, a `Literal` of strings (best as `type Mode = Literal[...]`), or
|
|
125
|
+
a list of possible values.
|
|
126
|
+
"""
|
|
127
|
+
return self._with(value_parser=into_value_parser(parser))
|
|
128
|
+
|
|
129
|
+
@overload
|
|
130
|
+
def num_args(self, exactly: int, /) -> Arg: ...
|
|
131
|
+
@overload
|
|
132
|
+
def num_args(self, min: int, max: int | None, /) -> Arg: ...
|
|
133
|
+
def num_args(self, *bounds: int | None) -> Arg:
|
|
134
|
+
"""Values per occurrence: `num_args(2)`, `num_args(0, 1)`, `num_args(1, None)` (unbounded)."""
|
|
135
|
+
invariant(
|
|
136
|
+
len(bounds) in (1, 2),
|
|
137
|
+
f"{self._owner}.num_args() takes 1 or 2 bounds, got {bounds}",
|
|
138
|
+
)
|
|
139
|
+
low = bounds[0]
|
|
140
|
+
high = bounds[-1]
|
|
141
|
+
if not isinstance(low, int) or isinstance(low, bool) or low < 0:
|
|
142
|
+
bug(f"{self._owner}.num_args(): min must be an int >= 0, got {low!r}")
|
|
143
|
+
if high is not None and (not isinstance(high, int) or isinstance(high, bool)):
|
|
144
|
+
bug(f"{self._owner}.num_args(): max must be an int or None, got {high!r}")
|
|
145
|
+
invariant(
|
|
146
|
+
high is None or high >= max(low, 1),
|
|
147
|
+
f"{self._owner}.num_args{bounds}: max must be >= min and >= 1; "
|
|
148
|
+
f"for an argument that takes no value, use action('set_true')",
|
|
149
|
+
)
|
|
150
|
+
return self._with(num_args=(low, high))
|
|
151
|
+
|
|
152
|
+
def default_value(self, value: str) -> Arg:
|
|
153
|
+
"""Used when the argument is absent from both the command line and the environment."""
|
|
154
|
+
return self.default_values([value])
|
|
155
|
+
|
|
156
|
+
def default_values(self, values: Iterable[str]) -> Arg:
|
|
157
|
+
collected = tuple(values)
|
|
158
|
+
invariant(
|
|
159
|
+
len(collected) > 0 and all(isinstance(value, str) for value in collected),
|
|
160
|
+
f"{self._owner}.default_values() takes a non-empty list of str, got {collected!r}",
|
|
161
|
+
)
|
|
162
|
+
return self._with(default_values=collected)
|
|
163
|
+
|
|
164
|
+
def default_missing_value(self, value: str) -> Arg:
|
|
165
|
+
"""Used when the argument is present with no value, e.g. `--color` for `num_args(0, 1)`."""
|
|
166
|
+
invariant(
|
|
167
|
+
isinstance(value, str),
|
|
168
|
+
f"{self._owner}.default_missing_value() takes a str, got {value!r}",
|
|
169
|
+
)
|
|
170
|
+
return self._with(default_missing_values=(value,))
|
|
171
|
+
|
|
172
|
+
def env(self, name: str) -> Arg:
|
|
173
|
+
"""Fall back to this environment variable. An empty value counts as unset."""
|
|
174
|
+
invariant(
|
|
175
|
+
isinstance(name, str) and name != "" and "=" not in name,
|
|
176
|
+
f"{self._owner}.env() takes a variable name, got {name!r}",
|
|
177
|
+
)
|
|
178
|
+
return self._with(env=name)
|
|
179
|
+
|
|
180
|
+
def conflicts_with(self, id: str) -> Arg:
|
|
181
|
+
return self._with(conflicts_with=self._spec.conflicts_with | {_check_id("Arg", id)})
|
|
182
|
+
|
|
183
|
+
def conflicts_with_all(self, ids: Iterable[str]) -> Arg:
|
|
184
|
+
checked = {_check_id("Arg", id) for id in ids}
|
|
185
|
+
return self._with(conflicts_with=self._spec.conflicts_with | checked)
|
|
186
|
+
|
|
187
|
+
def requires(self, id: str) -> Arg:
|
|
188
|
+
return self._with(requires=self._spec.requires | {_check_id("Arg", id)})
|
|
189
|
+
|
|
190
|
+
def allow_hyphen_values(self, yes: bool) -> Arg:
|
|
191
|
+
"""Accept values starting with `-`, such as `-5`, without `=` or `--`."""
|
|
192
|
+
return self._with(allow_hyphen_values=_check_bool(self._owner, "allow_hyphen_values", yes))
|
|
193
|
+
|
|
194
|
+
def value_delimiter(self, char: str) -> Arg:
|
|
195
|
+
"""Split each value on `char`: `--tags a,b,c`."""
|
|
196
|
+
invariant(
|
|
197
|
+
isinstance(char, str) and len(char) == 1,
|
|
198
|
+
f"{self._owner}.value_delimiter() takes one character, got {char!r}",
|
|
199
|
+
)
|
|
200
|
+
return self._with(value_delimiter=char)
|
|
201
|
+
|
|
202
|
+
def hide(self, yes: bool) -> Arg:
|
|
203
|
+
"""Leave the argument out of help and usage."""
|
|
204
|
+
return self._with(hide=_check_bool(self._owner, "hide", yes))
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _check_short(owner: str, method: str, char: object) -> str:
|
|
208
|
+
invariant(
|
|
209
|
+
isinstance(char, str) and len(char) == 1 and char not in "-=" and not char.isspace(),
|
|
210
|
+
f"{owner}.{method}() takes one character other than '-' or '=', got {char!r}",
|
|
211
|
+
)
|
|
212
|
+
return str(char)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _check_long(owner: str, method: str, name: object) -> str:
|
|
216
|
+
invariant(
|
|
217
|
+
isinstance(name, str)
|
|
218
|
+
and name != ""
|
|
219
|
+
and not name.startswith("-")
|
|
220
|
+
and "=" not in name
|
|
221
|
+
and not any(char.isspace() for char in name),
|
|
222
|
+
f"{owner}.{method}() takes a name without leading '-', '=' or spaces, got {name!r}",
|
|
223
|
+
)
|
|
224
|
+
return str(name)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class ArgGroup:
|
|
228
|
+
"""A named set of arguments: `ArgGroup("mode").args(["fast", "safe"]).required(True)`.
|
|
229
|
+
|
|
230
|
+
By default at most one member may be used (`multiple(False)`).
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
__slots__ = ("_spec",)
|
|
234
|
+
_spec: GroupSpec
|
|
235
|
+
|
|
236
|
+
def __init__(self, id: str) -> None:
|
|
237
|
+
self._spec = GroupSpec(id=_check_id("ArgGroup", id))
|
|
238
|
+
|
|
239
|
+
def __repr__(self) -> str:
|
|
240
|
+
return f"ArgGroup({self._spec.id!r})"
|
|
241
|
+
|
|
242
|
+
def _with(self, **changes: Any) -> ArgGroup:
|
|
243
|
+
new: ArgGroup = ArgGroup.__new__(ArgGroup)
|
|
244
|
+
new._spec = dataclasses.replace(self._spec, **changes)
|
|
245
|
+
return new
|
|
246
|
+
|
|
247
|
+
def arg(self, id: str) -> ArgGroup:
|
|
248
|
+
return self.args([id])
|
|
249
|
+
|
|
250
|
+
def args(self, ids: Iterable[str]) -> ArgGroup:
|
|
251
|
+
members = self._spec.args + tuple(_check_id("Arg", id) for id in ids)
|
|
252
|
+
invariant(
|
|
253
|
+
len(set(members)) == len(members),
|
|
254
|
+
f"ArgGroup({self._spec.id!r}): duplicate members in {members}",
|
|
255
|
+
)
|
|
256
|
+
return self._with(args=members)
|
|
257
|
+
|
|
258
|
+
def required(self, yes: bool) -> ArgGroup:
|
|
259
|
+
"""At least one member must be present."""
|
|
260
|
+
return self._with(required=_check_bool(repr(self), "required", yes))
|
|
261
|
+
|
|
262
|
+
def multiple(self, yes: bool) -> ArgGroup:
|
|
263
|
+
"""Allow more than one member at once."""
|
|
264
|
+
return self._with(multiple=_check_bool(repr(self), "multiple", yes))
|