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.
@@ -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
+ [![CI](https://github.com/Tomperez98/argbuilder/actions/workflows/ci.yml/badge.svg)](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
+ [![CI](https://github.com/Tomperez98/argbuilder/actions/workflows/ci.yml/badge.svg)](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