argbuilder 0.1.0__tar.gz
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-0.1.0/LICENSE +21 -0
- argbuilder-0.1.0/PKG-INFO +309 -0
- argbuilder-0.1.0/README.md +286 -0
- argbuilder-0.1.0/pyproject.toml +60 -0
- argbuilder-0.1.0/pyproject.toml.orig +51 -0
- argbuilder-0.1.0/src/argbuilder/__init__.py +75 -0
- argbuilder-0.1.0/src/argbuilder/_arg.py +264 -0
- argbuilder-0.1.0/src/argbuilder/_build.py +431 -0
- argbuilder-0.1.0/src/argbuilder/_command.py +222 -0
- argbuilder-0.1.0/src/argbuilder/_derive.py +702 -0
- argbuilder-0.1.0/src/argbuilder/_error.py +242 -0
- argbuilder-0.1.0/src/argbuilder/_help.py +157 -0
- argbuilder-0.1.0/src/argbuilder/_invariant.py +46 -0
- argbuilder-0.1.0/src/argbuilder/_matches.py +180 -0
- argbuilder-0.1.0/src/argbuilder/_parser.py +438 -0
- argbuilder-0.1.0/src/argbuilder/_spec.py +91 -0
- argbuilder-0.1.0/src/argbuilder/_style.py +103 -0
- argbuilder-0.1.0/src/argbuilder/_value_parser.py +208 -0
- argbuilder-0.1.0/src/argbuilder/py.typed +0 -0
argbuilder-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tomas Perez Alvarez
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: argbuilder
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Typed command-line parsing where definition bugs fail at once and user mistakes return values. No dependencies.
|
|
5
|
+
Keywords: cli,command-line,argument-parser,argparse,clap,typed,zero-dependency
|
|
6
|
+
Author: Tomas Perez Alvarez
|
|
7
|
+
Author-email: Tomas Perez Alvarez <tomasperezalvarez@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.12
|
|
18
|
+
Project-URL: Homepage, https://github.com/Tomperez98/argbuilder
|
|
19
|
+
Project-URL: Repository, https://github.com/Tomperez98/argbuilder
|
|
20
|
+
Project-URL: Issues, https://github.com/Tomperez98/argbuilder/issues
|
|
21
|
+
Project-URL: Changelog, https://github.com/Tomperez98/argbuilder/releases
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# argbuilder
|
|
25
|
+
|
|
26
|
+
[](https://github.com/Tomperez98/argbuilder/actions/workflows/ci.yml)
|
|
27
|
+
|
|
28
|
+
Build command-line interfaces in Python from typed, immutable builders — the
|
|
29
|
+
[clap] model.
|
|
30
|
+
|
|
31
|
+
A broken *definition* fails when the command is built, in CI, not in front of a
|
|
32
|
+
user. A broken *command line* comes back as an `Error` you can inspect, with the
|
|
33
|
+
message, typo suggestion and help ready to print.
|
|
34
|
+
|
|
35
|
+
> **Python 3.12+** · no dependencies ·
|
|
36
|
+
> [github.com/Tomperez98/argbuilder](https://github.com/Tomperez98/argbuilder)
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from argbuilder import Arg, Command, ValueValidation
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def cli() -> Command:
|
|
43
|
+
return (
|
|
44
|
+
Command("git")
|
|
45
|
+
.about("A fictional versioning CLI")
|
|
46
|
+
.version("1.0.0")
|
|
47
|
+
.subcommand_required(True)
|
|
48
|
+
.arg(Arg("verbose").short("v").long("verbose").action("count"))
|
|
49
|
+
.subcommand(
|
|
50
|
+
Command("push")
|
|
51
|
+
.about("Pushes things")
|
|
52
|
+
.arg(Arg("remote").required(True))
|
|
53
|
+
.arg(
|
|
54
|
+
Arg("port")
|
|
55
|
+
.short("p")
|
|
56
|
+
.long("port")
|
|
57
|
+
.value_parser(range(1, 65536))
|
|
58
|
+
.env("GIT_PORT")
|
|
59
|
+
.default_value("22")
|
|
60
|
+
)
|
|
61
|
+
.arg(Arg("force").short("f").long("force").action("set_true"))
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
if __name__ == "__main__":
|
|
67
|
+
matches = cli().get_matches() # reads sys.argv + os.environ, exits on error
|
|
68
|
+
match matches.subcommand():
|
|
69
|
+
case ("push", sub):
|
|
70
|
+
remote = sub.get_required("remote", str) # str: required(True), never None
|
|
71
|
+
port = sub.get_required("port", int) # int: it has a default
|
|
72
|
+
force = sub.get_flag("force") # bool
|
|
73
|
+
if remote.startswith("-"):
|
|
74
|
+
sub.error(ValueValidation(), "remote must not start with '-'").exit()
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
```text
|
|
78
|
+
$ git push origin -p 99999
|
|
79
|
+
error: invalid value '99999' for '--port <PORT>': 99999 is not in 1..=65535
|
|
80
|
+
|
|
81
|
+
Usage: git push [OPTIONS] <REMOTE>
|
|
82
|
+
|
|
83
|
+
For more information, try '--help'.
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
A fuller CLI is in [`examples/git.py`](examples/git.py)
|
|
87
|
+
(`uv run examples/git.py --help`).
|
|
88
|
+
|
|
89
|
+
## Or derive it from classes
|
|
90
|
+
|
|
91
|
+
Like clap's `#[derive(Parser)]`: fields describe the arguments, their types
|
|
92
|
+
pick the action, and parsing returns an instance. The derive only builds a
|
|
93
|
+
`Command`, so help, errors and the rules above are the same.
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
from __future__ import annotations # lets Git name Clone before it's defined
|
|
97
|
+
|
|
98
|
+
from pathlib import Path
|
|
99
|
+
|
|
100
|
+
from argbuilder import Parser, arg
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class Git(Parser, version="1.0.0"):
|
|
104
|
+
"""A fictional versioning CLI.""" # the docstring's first paragraph is `about`
|
|
105
|
+
|
|
106
|
+
verbose: int = arg(short=True, long=True, action="count", global_=True)
|
|
107
|
+
command: Clone | Push # add `| None = None` to make it optional
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class Clone(Parser):
|
|
111
|
+
"""Clones repos."""
|
|
112
|
+
|
|
113
|
+
remote: str
|
|
114
|
+
dir: Path | None = None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class Push(Parser):
|
|
118
|
+
"""Pushes things."""
|
|
119
|
+
|
|
120
|
+
port: int = arg(short=True, long=True, value_parser=range(1, 65536), default=22)
|
|
121
|
+
force: bool = arg(short=True, long=True)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
git = Git.parse() # or Git.try_parse_from(argv, env) -> Git | Error
|
|
125
|
+
match git.command:
|
|
126
|
+
case Push(port=port, force=force):
|
|
127
|
+
...
|
|
128
|
+
case Clone(remote=remote):
|
|
129
|
+
...
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
| Field | Becomes |
|
|
133
|
+
|---|---|
|
|
134
|
+
| `x: T` | required; `T` picks the value parser (`int`, `Path`, a `Literal` alias, any `str -> T` callable) |
|
|
135
|
+
| `x: T = 22` or `arg(default=22)` | `default_value("22")`, checked to parse back to `22` |
|
|
136
|
+
| `x: T \| None` | optional, `None` when absent |
|
|
137
|
+
| `x: tuple[T, ...]` | `append`: every value, `()` when absent; `arg(required=True)` for at least one |
|
|
138
|
+
| `x: bool` | `set_true` flag; `arg(action="set_false")` for the opposite |
|
|
139
|
+
| `x: int = arg(action="count")` | `count` flag |
|
|
140
|
+
| `x: A \| B` (`Parser` classes) | the subcommand, named in kebab-case (`RemoteAdd` → `remote-add`) |
|
|
141
|
+
| `x: Shared` (an `Args` class) | its fields, flattened in (clap's `#[command(flatten)]`) |
|
|
142
|
+
|
|
143
|
+
Without `short` or `long` a field is positional. `short=True` / `long=True`
|
|
144
|
+
/ `env=True` derive `-d` / `--dry-run` / `DRY_RUN` from the field name.
|
|
145
|
+
Command options go on the class: `name`, `about`, `version`, `aliases`,
|
|
146
|
+
`visible_aliases`, `arg_required_else_help`, `disable_help_flag`,
|
|
147
|
+
`disable_version_flag`, `disable_help_subcommand`.
|
|
148
|
+
|
|
149
|
+
Subclasses are frozen, keyword-only dataclasses (don't add `@dataclass`),
|
|
150
|
+
and type checkers see them that way. Definition bugs still panic: class
|
|
151
|
+
options at the `class` statement, fields at the first `to_command()` or
|
|
152
|
+
parse, since annotations may name classes defined further down. Test them
|
|
153
|
+
with `Git.to_command().debug_assert()`.
|
|
154
|
+
|
|
155
|
+
For anything the derive doesn't cover, extend the builder and read the
|
|
156
|
+
result back: `Git.from_arg_matches(Git.to_command().arg(...).get_matches())`.
|
|
157
|
+
The same CLI as [`examples/git.py`](examples/git.py), derived, is in
|
|
158
|
+
[`examples/git_derive.py`](examples/git_derive.py).
|
|
159
|
+
|
|
160
|
+
Argument types are read at runtime, so keep their imports out of
|
|
161
|
+
`if TYPE_CHECKING:`. With ruff's `TC` rules, add:
|
|
162
|
+
|
|
163
|
+
```toml
|
|
164
|
+
[lint.flake8-type-checking]
|
|
165
|
+
runtime-evaluated-base-classes = ["argbuilder.Parser", "argbuilder.Args"]
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Install
|
|
169
|
+
|
|
170
|
+
Not on PyPI yet; install from source:
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
uv add git+https://github.com/Tomperez98/argbuilder
|
|
174
|
+
# or
|
|
175
|
+
pip install "argbuilder @ git+https://github.com/Tomperez98/argbuilder"
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## The contract: bugs panic, user mistakes return values
|
|
179
|
+
|
|
180
|
+
| Who made the mistake | Example | What happens |
|
|
181
|
+
|---|---|---|
|
|
182
|
+
| **You**, defining the CLI | two args claim `-v`, `required(True)` plus a default, a default the parser rejects, `short("ab")` | `AssertionError`, naming the command and argument. Builder-local mistakes fail at the builder call, cross-argument ones at build. Raised explicitly, so `python -O` doesn't strip them. |
|
|
183
|
+
| **You**, reading matches | unknown id, `get_one("port", str)` on an int, `get_one` on an `Append` arg, `get_flag` on a value arg, `get_required` on an arg that can be absent | `AssertionError` at the call |
|
|
184
|
+
| **The user**, typing the command | unknown flag, bad value, missing required arg, `--help` | `try_get_matches_from` **returns** an `Error`. `get_matches` prints it and exits (0 for help/version, 2 otherwise). |
|
|
185
|
+
|
|
186
|
+
That second row is why matches are read with typed getters, not a dictionary:
|
|
187
|
+
|
|
188
|
+
| Getter | Use it for |
|
|
189
|
+
|---|---|
|
|
190
|
+
| `get_required(id, T)` | an arg that is `required(True)` or has a default — never `None` |
|
|
191
|
+
| `get_one(id, T)` | an optional value, as `T \| None` |
|
|
192
|
+
| `get_many(id, T)` | `append` and multi-value args: a `tuple`, empty when absent |
|
|
193
|
+
| `get_flag(id)` | a `set_true` / `set_false` flag, as `bool` |
|
|
194
|
+
| `get_count(id)` | a `count` flag, as `int` |
|
|
195
|
+
| `contains_id(id)` | whether a value is present from any source, defaults included |
|
|
196
|
+
| `value_source(id)` | `"default_value"`, `"env_variable"` or `"command_line"` |
|
|
197
|
+
|
|
198
|
+
Catch definition bugs in CI the way clap recommends:
|
|
199
|
+
|
|
200
|
+
```python
|
|
201
|
+
def test_cli() -> None:
|
|
202
|
+
cli().debug_assert()
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Test parsing with no process involved. The parser is pure, and the environment
|
|
206
|
+
is a parameter that defaults to empty:
|
|
207
|
+
|
|
208
|
+
```python
|
|
209
|
+
from argbuilder import ArgMatches
|
|
210
|
+
|
|
211
|
+
result = cli().try_get_matches_from(["git", "push", "origin"], env={"GIT_PORT": "8080"})
|
|
212
|
+
assert isinstance(result, ArgMatches)
|
|
213
|
+
sub = result.subcommand_matches("push")
|
|
214
|
+
assert sub is not None and sub.get_required("port", int) == 8080
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Only `get_matches()` reads `sys.argv` and `os.environ` for parsing. Printing
|
|
218
|
+
(`Error.exit()`) is the only other place that looks at the process: it checks
|
|
219
|
+
the terminal it writes to, below.
|
|
220
|
+
|
|
221
|
+
## Terminal output
|
|
222
|
+
|
|
223
|
+
When `get_matches()` prints help or an error, it styles the output for the
|
|
224
|
+
stream it writes to:
|
|
225
|
+
|
|
226
|
+
- **Color** only on a terminal, and never when `NO_COLOR` is set or `TERM=dumb`.
|
|
227
|
+
- **Wrapping** of help text to the terminal width (or `COLUMNS`), capped at
|
|
228
|
+
100 columns. Help moves below its flag when the terminal is too narrow for
|
|
229
|
+
two columns.
|
|
230
|
+
|
|
231
|
+
Rendering itself is pure and plain by default. Pass a `Style` to see what a
|
|
232
|
+
terminal gets:
|
|
233
|
+
|
|
234
|
+
```python
|
|
235
|
+
from argbuilder import Style
|
|
236
|
+
|
|
237
|
+
assert "\x1b[" not in cli().render_help()
|
|
238
|
+
print(cli().render_help(Style(color=True, width=60)))
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
## clap → argbuilder
|
|
242
|
+
|
|
243
|
+
Coming from [clap]? The builder API maps almost one to one:
|
|
244
|
+
|
|
245
|
+
| clap | argbuilder |
|
|
246
|
+
|---|---|
|
|
247
|
+
| `Command::new("x")` / `Arg::new("x")` | `Command("x")` / `Arg("x")`, both immutable (each method returns a new value) |
|
|
248
|
+
| `.value_parser(value_parser!(u16).range(1..))` | `.value_parser(range(1, 65536))`, `ValueParser.integer(min=1)` |
|
|
249
|
+
| `.value_parser(["a", "b"])`, `ValueEnum` | `.value_parser(["a", "b"])`, `.value_parser(Mode)` with `type Mode = Literal["a", "b"]` |
|
|
250
|
+
| `value_parser!(PathBuf)` | `.value_parser(Path)`. Any `str -> T` callable works, and a `ValueError` becomes a user error. |
|
|
251
|
+
| `ArgAction::{Set, Append, SetTrue, SetFalse, Count, Help, Version}` | `"set"`, `"append"`, `"set_true"`, `"set_false"`, `"count"`, `"help"`, `"version"` (default: `"set"`) |
|
|
252
|
+
| `ValueSource::{DefaultValue, EnvVariable, CommandLine}` | `"default_value"`, `"env_variable"`, `"command_line"` |
|
|
253
|
+
| `ErrorKind::InvalidValue`, plus `Error::get(ContextKind::…)` | `InvalidValue(argument, value, reason, …)`: the context is fields on the kind |
|
|
254
|
+
| `.num_args(1..)`, `.num_args(0..=1)`, `.num_args(2)` | `.num_args(1, None)`, `.num_args(0, 1)`, `.num_args(2)` |
|
|
255
|
+
| `get_one::<T>`, `get_many::<T>`, `get_flag`, `get_count` | `get_one(id, T)`, `get_many(id, T)` (a tuple, empty if absent), `get_flag`, `get_count` |
|
|
256
|
+
| `get_one::<T>(id).expect(..)` on a required or defaulted arg | `get_required(id, T)` |
|
|
257
|
+
| `try_get_matches_from` → `Result<ArgMatches, Error>` | `try_get_matches_from` → `ArgMatches \| Error` |
|
|
258
|
+
| `Error::exit`, `ErrorKind`, `Command::error` | `Error.exit()`, `ErrorKind`, `Command.error()`, and `ArgMatches.error()` for the subcommand you're in |
|
|
259
|
+
| `Arg::global(true)` | `.global_(True)` (`global` is a Python keyword). See below for how repeats work. |
|
|
260
|
+
| `alias`, `visible_alias`, `short_alias`, `visible_short_alias` | The same names, on `Arg` and (`alias` / `visible_alias`) on `Command`. Call once per alias. |
|
|
261
|
+
| the `help` subcommand, `disable_help_subcommand` | The same: `git help`, `git help push` |
|
|
262
|
+
|
|
263
|
+
`ArgAction` and `ValueSource` are `Literal` strings, so a type checker
|
|
264
|
+
catches a typo like `.action("cout")`, and at runtime it panics with a
|
|
265
|
+
suggestion. `ErrorKind` is a union of frozen dataclasses that carry what went
|
|
266
|
+
wrong, so you can act on an error without parsing its message:
|
|
267
|
+
|
|
268
|
+
```python
|
|
269
|
+
match result.kind:
|
|
270
|
+
case DisplayHelp() | DisplayVersion():
|
|
271
|
+
...
|
|
272
|
+
case InvalidValue(argument=argument, value=None):
|
|
273
|
+
... # the option was given with no value
|
|
274
|
+
case UnknownArgument(argument=typed, suggestion=str(closest)):
|
|
275
|
+
... # e.g. typed "--prot", closest "--port"
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
Also supported: `env`, `default_value(s)`, `default_missing_value`,
|
|
279
|
+
`conflicts_with(_all)`, `requires`, `ArgGroup` (`required`, `multiple`),
|
|
280
|
+
`allow_hyphen_values`, `value_delimiter`, `hide`, `arg_required_else_help`,
|
|
281
|
+
`disable_help_flag` / `disable_version_flag`, typo suggestions for arguments,
|
|
282
|
+
subcommands and values, and a tip when an option is used at the wrong level
|
|
283
|
+
(`git push -V`: "'-V' is an option of 'git'; put it before 'push'").
|
|
284
|
+
|
|
285
|
+
A global option is read the same way at every level (`matches.get_count("verbose")`
|
|
286
|
+
and `sub.get_count("verbose")` agree), and the whole command line counts as
|
|
287
|
+
one list of occurrences. `git -v push -v` counts 2, and `append` values add up
|
|
288
|
+
left to right. A `set` option given at two levels is an error ("cannot be used
|
|
289
|
+
multiple times"). clap would silently keep the deeper value instead. Globals
|
|
290
|
+
can't be positional or required, and their `conflicts_with` / `requires` may
|
|
291
|
+
only name other globals, because every subcommand has to be able to check them.
|
|
292
|
+
|
|
293
|
+
Not implemented yet: `last` / trailing var-args.
|
|
294
|
+
|
|
295
|
+
## Development
|
|
296
|
+
|
|
297
|
+
CI runs the same checks on Python 3.12–3.14
|
|
298
|
+
([`.github/workflows/ci.yml`](.github/workflows/ci.yml)):
|
|
299
|
+
|
|
300
|
+
```bash
|
|
301
|
+
uv run pytest # tests
|
|
302
|
+
uv run ruff check # lint
|
|
303
|
+
uv run ty check # type check
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
Releases publish to PyPI from CI when a `v*` tag is pushed
|
|
307
|
+
([`.github/workflows/release.yml`](.github/workflows/release.yml)).
|
|
308
|
+
|
|
309
|
+
[clap]: https://docs.rs/clap/latest/clap/_tutorial/index.html
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
# argbuilder
|
|
2
|
+
|
|
3
|
+
[](https://github.com/Tomperez98/argbuilder/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
Build command-line interfaces in Python from typed, immutable builders — the
|
|
6
|
+
[clap] model.
|
|
7
|
+
|
|
8
|
+
A broken *definition* fails when the command is built, in CI, not in front of a
|
|
9
|
+
user. A broken *command line* comes back as an `Error` you can inspect, with the
|
|
10
|
+
message, typo suggestion and help ready to print.
|
|
11
|
+
|
|
12
|
+
> **Python 3.12+** · no dependencies ·
|
|
13
|
+
> [github.com/Tomperez98/argbuilder](https://github.com/Tomperez98/argbuilder)
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from argbuilder import Arg, Command, ValueValidation
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def cli() -> Command:
|
|
20
|
+
return (
|
|
21
|
+
Command("git")
|
|
22
|
+
.about("A fictional versioning CLI")
|
|
23
|
+
.version("1.0.0")
|
|
24
|
+
.subcommand_required(True)
|
|
25
|
+
.arg(Arg("verbose").short("v").long("verbose").action("count"))
|
|
26
|
+
.subcommand(
|
|
27
|
+
Command("push")
|
|
28
|
+
.about("Pushes things")
|
|
29
|
+
.arg(Arg("remote").required(True))
|
|
30
|
+
.arg(
|
|
31
|
+
Arg("port")
|
|
32
|
+
.short("p")
|
|
33
|
+
.long("port")
|
|
34
|
+
.value_parser(range(1, 65536))
|
|
35
|
+
.env("GIT_PORT")
|
|
36
|
+
.default_value("22")
|
|
37
|
+
)
|
|
38
|
+
.arg(Arg("force").short("f").long("force").action("set_true"))
|
|
39
|
+
)
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
if __name__ == "__main__":
|
|
44
|
+
matches = cli().get_matches() # reads sys.argv + os.environ, exits on error
|
|
45
|
+
match matches.subcommand():
|
|
46
|
+
case ("push", sub):
|
|
47
|
+
remote = sub.get_required("remote", str) # str: required(True), never None
|
|
48
|
+
port = sub.get_required("port", int) # int: it has a default
|
|
49
|
+
force = sub.get_flag("force") # bool
|
|
50
|
+
if remote.startswith("-"):
|
|
51
|
+
sub.error(ValueValidation(), "remote must not start with '-'").exit()
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
```text
|
|
55
|
+
$ git push origin -p 99999
|
|
56
|
+
error: invalid value '99999' for '--port <PORT>': 99999 is not in 1..=65535
|
|
57
|
+
|
|
58
|
+
Usage: git push [OPTIONS] <REMOTE>
|
|
59
|
+
|
|
60
|
+
For more information, try '--help'.
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
A fuller CLI is in [`examples/git.py`](examples/git.py)
|
|
64
|
+
(`uv run examples/git.py --help`).
|
|
65
|
+
|
|
66
|
+
## Or derive it from classes
|
|
67
|
+
|
|
68
|
+
Like clap's `#[derive(Parser)]`: fields describe the arguments, their types
|
|
69
|
+
pick the action, and parsing returns an instance. The derive only builds a
|
|
70
|
+
`Command`, so help, errors and the rules above are the same.
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from __future__ import annotations # lets Git name Clone before it's defined
|
|
74
|
+
|
|
75
|
+
from pathlib import Path
|
|
76
|
+
|
|
77
|
+
from argbuilder import Parser, arg
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Git(Parser, version="1.0.0"):
|
|
81
|
+
"""A fictional versioning CLI.""" # the docstring's first paragraph is `about`
|
|
82
|
+
|
|
83
|
+
verbose: int = arg(short=True, long=True, action="count", global_=True)
|
|
84
|
+
command: Clone | Push # add `| None = None` to make it optional
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class Clone(Parser):
|
|
88
|
+
"""Clones repos."""
|
|
89
|
+
|
|
90
|
+
remote: str
|
|
91
|
+
dir: Path | None = None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class Push(Parser):
|
|
95
|
+
"""Pushes things."""
|
|
96
|
+
|
|
97
|
+
port: int = arg(short=True, long=True, value_parser=range(1, 65536), default=22)
|
|
98
|
+
force: bool = arg(short=True, long=True)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
git = Git.parse() # or Git.try_parse_from(argv, env) -> Git | Error
|
|
102
|
+
match git.command:
|
|
103
|
+
case Push(port=port, force=force):
|
|
104
|
+
...
|
|
105
|
+
case Clone(remote=remote):
|
|
106
|
+
...
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
| Field | Becomes |
|
|
110
|
+
|---|---|
|
|
111
|
+
| `x: T` | required; `T` picks the value parser (`int`, `Path`, a `Literal` alias, any `str -> T` callable) |
|
|
112
|
+
| `x: T = 22` or `arg(default=22)` | `default_value("22")`, checked to parse back to `22` |
|
|
113
|
+
| `x: T \| None` | optional, `None` when absent |
|
|
114
|
+
| `x: tuple[T, ...]` | `append`: every value, `()` when absent; `arg(required=True)` for at least one |
|
|
115
|
+
| `x: bool` | `set_true` flag; `arg(action="set_false")` for the opposite |
|
|
116
|
+
| `x: int = arg(action="count")` | `count` flag |
|
|
117
|
+
| `x: A \| B` (`Parser` classes) | the subcommand, named in kebab-case (`RemoteAdd` → `remote-add`) |
|
|
118
|
+
| `x: Shared` (an `Args` class) | its fields, flattened in (clap's `#[command(flatten)]`) |
|
|
119
|
+
|
|
120
|
+
Without `short` or `long` a field is positional. `short=True` / `long=True`
|
|
121
|
+
/ `env=True` derive `-d` / `--dry-run` / `DRY_RUN` from the field name.
|
|
122
|
+
Command options go on the class: `name`, `about`, `version`, `aliases`,
|
|
123
|
+
`visible_aliases`, `arg_required_else_help`, `disable_help_flag`,
|
|
124
|
+
`disable_version_flag`, `disable_help_subcommand`.
|
|
125
|
+
|
|
126
|
+
Subclasses are frozen, keyword-only dataclasses (don't add `@dataclass`),
|
|
127
|
+
and type checkers see them that way. Definition bugs still panic: class
|
|
128
|
+
options at the `class` statement, fields at the first `to_command()` or
|
|
129
|
+
parse, since annotations may name classes defined further down. Test them
|
|
130
|
+
with `Git.to_command().debug_assert()`.
|
|
131
|
+
|
|
132
|
+
For anything the derive doesn't cover, extend the builder and read the
|
|
133
|
+
result back: `Git.from_arg_matches(Git.to_command().arg(...).get_matches())`.
|
|
134
|
+
The same CLI as [`examples/git.py`](examples/git.py), derived, is in
|
|
135
|
+
[`examples/git_derive.py`](examples/git_derive.py).
|
|
136
|
+
|
|
137
|
+
Argument types are read at runtime, so keep their imports out of
|
|
138
|
+
`if TYPE_CHECKING:`. With ruff's `TC` rules, add:
|
|
139
|
+
|
|
140
|
+
```toml
|
|
141
|
+
[lint.flake8-type-checking]
|
|
142
|
+
runtime-evaluated-base-classes = ["argbuilder.Parser", "argbuilder.Args"]
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Install
|
|
146
|
+
|
|
147
|
+
Not on PyPI yet; install from source:
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
uv add git+https://github.com/Tomperez98/argbuilder
|
|
151
|
+
# or
|
|
152
|
+
pip install "argbuilder @ git+https://github.com/Tomperez98/argbuilder"
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## The contract: bugs panic, user mistakes return values
|
|
156
|
+
|
|
157
|
+
| Who made the mistake | Example | What happens |
|
|
158
|
+
|---|---|---|
|
|
159
|
+
| **You**, defining the CLI | two args claim `-v`, `required(True)` plus a default, a default the parser rejects, `short("ab")` | `AssertionError`, naming the command and argument. Builder-local mistakes fail at the builder call, cross-argument ones at build. Raised explicitly, so `python -O` doesn't strip them. |
|
|
160
|
+
| **You**, reading matches | unknown id, `get_one("port", str)` on an int, `get_one` on an `Append` arg, `get_flag` on a value arg, `get_required` on an arg that can be absent | `AssertionError` at the call |
|
|
161
|
+
| **The user**, typing the command | unknown flag, bad value, missing required arg, `--help` | `try_get_matches_from` **returns** an `Error`. `get_matches` prints it and exits (0 for help/version, 2 otherwise). |
|
|
162
|
+
|
|
163
|
+
That second row is why matches are read with typed getters, not a dictionary:
|
|
164
|
+
|
|
165
|
+
| Getter | Use it for |
|
|
166
|
+
|---|---|
|
|
167
|
+
| `get_required(id, T)` | an arg that is `required(True)` or has a default — never `None` |
|
|
168
|
+
| `get_one(id, T)` | an optional value, as `T \| None` |
|
|
169
|
+
| `get_many(id, T)` | `append` and multi-value args: a `tuple`, empty when absent |
|
|
170
|
+
| `get_flag(id)` | a `set_true` / `set_false` flag, as `bool` |
|
|
171
|
+
| `get_count(id)` | a `count` flag, as `int` |
|
|
172
|
+
| `contains_id(id)` | whether a value is present from any source, defaults included |
|
|
173
|
+
| `value_source(id)` | `"default_value"`, `"env_variable"` or `"command_line"` |
|
|
174
|
+
|
|
175
|
+
Catch definition bugs in CI the way clap recommends:
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
def test_cli() -> None:
|
|
179
|
+
cli().debug_assert()
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Test parsing with no process involved. The parser is pure, and the environment
|
|
183
|
+
is a parameter that defaults to empty:
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
from argbuilder import ArgMatches
|
|
187
|
+
|
|
188
|
+
result = cli().try_get_matches_from(["git", "push", "origin"], env={"GIT_PORT": "8080"})
|
|
189
|
+
assert isinstance(result, ArgMatches)
|
|
190
|
+
sub = result.subcommand_matches("push")
|
|
191
|
+
assert sub is not None and sub.get_required("port", int) == 8080
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Only `get_matches()` reads `sys.argv` and `os.environ` for parsing. Printing
|
|
195
|
+
(`Error.exit()`) is the only other place that looks at the process: it checks
|
|
196
|
+
the terminal it writes to, below.
|
|
197
|
+
|
|
198
|
+
## Terminal output
|
|
199
|
+
|
|
200
|
+
When `get_matches()` prints help or an error, it styles the output for the
|
|
201
|
+
stream it writes to:
|
|
202
|
+
|
|
203
|
+
- **Color** only on a terminal, and never when `NO_COLOR` is set or `TERM=dumb`.
|
|
204
|
+
- **Wrapping** of help text to the terminal width (or `COLUMNS`), capped at
|
|
205
|
+
100 columns. Help moves below its flag when the terminal is too narrow for
|
|
206
|
+
two columns.
|
|
207
|
+
|
|
208
|
+
Rendering itself is pure and plain by default. Pass a `Style` to see what a
|
|
209
|
+
terminal gets:
|
|
210
|
+
|
|
211
|
+
```python
|
|
212
|
+
from argbuilder import Style
|
|
213
|
+
|
|
214
|
+
assert "\x1b[" not in cli().render_help()
|
|
215
|
+
print(cli().render_help(Style(color=True, width=60)))
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
## clap → argbuilder
|
|
219
|
+
|
|
220
|
+
Coming from [clap]? The builder API maps almost one to one:
|
|
221
|
+
|
|
222
|
+
| clap | argbuilder |
|
|
223
|
+
|---|---|
|
|
224
|
+
| `Command::new("x")` / `Arg::new("x")` | `Command("x")` / `Arg("x")`, both immutable (each method returns a new value) |
|
|
225
|
+
| `.value_parser(value_parser!(u16).range(1..))` | `.value_parser(range(1, 65536))`, `ValueParser.integer(min=1)` |
|
|
226
|
+
| `.value_parser(["a", "b"])`, `ValueEnum` | `.value_parser(["a", "b"])`, `.value_parser(Mode)` with `type Mode = Literal["a", "b"]` |
|
|
227
|
+
| `value_parser!(PathBuf)` | `.value_parser(Path)`. Any `str -> T` callable works, and a `ValueError` becomes a user error. |
|
|
228
|
+
| `ArgAction::{Set, Append, SetTrue, SetFalse, Count, Help, Version}` | `"set"`, `"append"`, `"set_true"`, `"set_false"`, `"count"`, `"help"`, `"version"` (default: `"set"`) |
|
|
229
|
+
| `ValueSource::{DefaultValue, EnvVariable, CommandLine}` | `"default_value"`, `"env_variable"`, `"command_line"` |
|
|
230
|
+
| `ErrorKind::InvalidValue`, plus `Error::get(ContextKind::…)` | `InvalidValue(argument, value, reason, …)`: the context is fields on the kind |
|
|
231
|
+
| `.num_args(1..)`, `.num_args(0..=1)`, `.num_args(2)` | `.num_args(1, None)`, `.num_args(0, 1)`, `.num_args(2)` |
|
|
232
|
+
| `get_one::<T>`, `get_many::<T>`, `get_flag`, `get_count` | `get_one(id, T)`, `get_many(id, T)` (a tuple, empty if absent), `get_flag`, `get_count` |
|
|
233
|
+
| `get_one::<T>(id).expect(..)` on a required or defaulted arg | `get_required(id, T)` |
|
|
234
|
+
| `try_get_matches_from` → `Result<ArgMatches, Error>` | `try_get_matches_from` → `ArgMatches \| Error` |
|
|
235
|
+
| `Error::exit`, `ErrorKind`, `Command::error` | `Error.exit()`, `ErrorKind`, `Command.error()`, and `ArgMatches.error()` for the subcommand you're in |
|
|
236
|
+
| `Arg::global(true)` | `.global_(True)` (`global` is a Python keyword). See below for how repeats work. |
|
|
237
|
+
| `alias`, `visible_alias`, `short_alias`, `visible_short_alias` | The same names, on `Arg` and (`alias` / `visible_alias`) on `Command`. Call once per alias. |
|
|
238
|
+
| the `help` subcommand, `disable_help_subcommand` | The same: `git help`, `git help push` |
|
|
239
|
+
|
|
240
|
+
`ArgAction` and `ValueSource` are `Literal` strings, so a type checker
|
|
241
|
+
catches a typo like `.action("cout")`, and at runtime it panics with a
|
|
242
|
+
suggestion. `ErrorKind` is a union of frozen dataclasses that carry what went
|
|
243
|
+
wrong, so you can act on an error without parsing its message:
|
|
244
|
+
|
|
245
|
+
```python
|
|
246
|
+
match result.kind:
|
|
247
|
+
case DisplayHelp() | DisplayVersion():
|
|
248
|
+
...
|
|
249
|
+
case InvalidValue(argument=argument, value=None):
|
|
250
|
+
... # the option was given with no value
|
|
251
|
+
case UnknownArgument(argument=typed, suggestion=str(closest)):
|
|
252
|
+
... # e.g. typed "--prot", closest "--port"
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Also supported: `env`, `default_value(s)`, `default_missing_value`,
|
|
256
|
+
`conflicts_with(_all)`, `requires`, `ArgGroup` (`required`, `multiple`),
|
|
257
|
+
`allow_hyphen_values`, `value_delimiter`, `hide`, `arg_required_else_help`,
|
|
258
|
+
`disable_help_flag` / `disable_version_flag`, typo suggestions for arguments,
|
|
259
|
+
subcommands and values, and a tip when an option is used at the wrong level
|
|
260
|
+
(`git push -V`: "'-V' is an option of 'git'; put it before 'push'").
|
|
261
|
+
|
|
262
|
+
A global option is read the same way at every level (`matches.get_count("verbose")`
|
|
263
|
+
and `sub.get_count("verbose")` agree), and the whole command line counts as
|
|
264
|
+
one list of occurrences. `git -v push -v` counts 2, and `append` values add up
|
|
265
|
+
left to right. A `set` option given at two levels is an error ("cannot be used
|
|
266
|
+
multiple times"). clap would silently keep the deeper value instead. Globals
|
|
267
|
+
can't be positional or required, and their `conflicts_with` / `requires` may
|
|
268
|
+
only name other globals, because every subcommand has to be able to check them.
|
|
269
|
+
|
|
270
|
+
Not implemented yet: `last` / trailing var-args.
|
|
271
|
+
|
|
272
|
+
## Development
|
|
273
|
+
|
|
274
|
+
CI runs the same checks on Python 3.12–3.14
|
|
275
|
+
([`.github/workflows/ci.yml`](.github/workflows/ci.yml)):
|
|
276
|
+
|
|
277
|
+
```bash
|
|
278
|
+
uv run pytest # tests
|
|
279
|
+
uv run ruff check # lint
|
|
280
|
+
uv run ty check # type check
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
Releases publish to PyPI from CI when a `v*` tag is pushed
|
|
284
|
+
([`.github/workflows/release.yml`](.github/workflows/release.yml)).
|
|
285
|
+
|
|
286
|
+
[clap]: https://docs.rs/clap/latest/clap/_tutorial/index.html
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "argbuilder"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Typed command-line parsing where definition bugs fail at once and user mistakes return values. No dependencies."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
keywords = [
|
|
9
|
+
"cli",
|
|
10
|
+
"command-line",
|
|
11
|
+
"argument-parser",
|
|
12
|
+
"argparse",
|
|
13
|
+
"clap",
|
|
14
|
+
"typed",
|
|
15
|
+
"zero-dependency",
|
|
16
|
+
]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 3 - Alpha",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Programming Language :: Python :: 3.14",
|
|
24
|
+
"Typing :: Typed",
|
|
25
|
+
]
|
|
26
|
+
requires-python = ">=3.12"
|
|
27
|
+
dependencies = []
|
|
28
|
+
|
|
29
|
+
[[project.authors]]
|
|
30
|
+
name = "Tomas Perez Alvarez"
|
|
31
|
+
email = "tomasperezalvarez@gmail.com"
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://github.com/Tomperez98/argbuilder"
|
|
35
|
+
Repository = "https://github.com/Tomperez98/argbuilder"
|
|
36
|
+
Issues = "https://github.com/Tomperez98/argbuilder/issues"
|
|
37
|
+
Changelog = "https://github.com/Tomperez98/argbuilder/releases"
|
|
38
|
+
|
|
39
|
+
[dependency-groups]
|
|
40
|
+
dev = [
|
|
41
|
+
"pytest>=9.1.1",
|
|
42
|
+
"pytest-cov>=7.1.0",
|
|
43
|
+
"ruff>=0.16.9",
|
|
44
|
+
"ty>=0.0.84",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
[build-system]
|
|
48
|
+
requires = ["uv_build>=0.12.17,<0.13.0"]
|
|
49
|
+
build-backend = "uv_build"
|
|
50
|
+
|
|
51
|
+
[tool.pytest.ini_options]
|
|
52
|
+
addopts = "--cov=src --cov-report=term-missing --cov-report=term"
|
|
53
|
+
|
|
54
|
+
[tool.coverage.run]
|
|
55
|
+
branch = true
|
|
56
|
+
source = ["src"]
|
|
57
|
+
|
|
58
|
+
[tool.coverage.report]
|
|
59
|
+
show_missing = true
|
|
60
|
+
skip_empty = true
|