argspec 0.3.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.
- argspec-0.3.0/.gitignore +4 -0
- argspec-0.3.0/LICENSE +21 -0
- argspec-0.3.0/PKG-INFO +277 -0
- argspec-0.3.0/README.md +249 -0
- argspec-0.3.0/argspec/__init__.py +5 -0
- argspec-0.3.0/argspec/argspec.py +44 -0
- argspec-0.3.0/argspec/metadata.py +86 -0
- argspec-0.3.0/argspec/parse.py +438 -0
- argspec-0.3.0/argspec/py.typed +0 -0
- argspec-0.3.0/pyproject.toml +72 -0
argspec-0.3.0/.gitignore
ADDED
argspec-0.3.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 lilellia
|
|
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.
|
argspec-0.3.0/PKG-INFO
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: argspec
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: A library for cleanly and succinctly performing type-safe command-line argument parsing via a declarative interface.
|
|
5
|
+
Project-URL: repository, https://github.com/lilellia/argspec
|
|
6
|
+
Project-URL: Bug Tracker, https://github.com/lilellia/argspec/issues
|
|
7
|
+
Author-email: Lily Ellington <lilell_@outlook.com>
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: argparse,argument-parser,argument-parsing,arguments,cli,command-line,type-safe
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Topic :: Utilities
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Requires-Dist: typewire>=0.1.0
|
|
26
|
+
Requires-Dist: typing-extensions>=4.15.0
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# argspec
|
|
30
|
+
|
|
31
|
+
A library for cleanly and succinctly performing type-safe command-line argument parsing via a declarative interface.
|
|
32
|
+
|
|
33
|
+
## Why `argspec`?
|
|
34
|
+
|
|
35
|
+
I view argument parsing as "the bit that happens before I can actually run my code". It's not part of my problem solving. It's literally just boilerplate to get information into my program so that my program can do its thing. As a result, I want it to be as minimal and as painless as possible. `argspec` aims to make it as invisible as possible without being magic.
|
|
36
|
+
|
|
37
|
+
```py
|
|
38
|
+
from argspec import ArgSpec, positional, option, flag
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
class Args(ArgSpec):
|
|
42
|
+
path: Path = positional(help="the path to read")
|
|
43
|
+
limit: int = option(10, aliases=["-L"], help="the max number of tries to try doing the thing")
|
|
44
|
+
verbose: bool = flag(short=True, help="enable verbose logging") # flags default to False, and short=True gives the -v alias
|
|
45
|
+
send_notifications: bool = flag(aliases=["-n", "--notif"], help="send all notifications")
|
|
46
|
+
|
|
47
|
+
args = Args.from_argv() # <-- .from_argv uses sys.argv[1:] by default, but you can provide a list manually if you want
|
|
48
|
+
print(args) # <-- an object with full type inference and autocomplete
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Of course, you also get a help message (accessible manually by `Args.__argspec_schema__.help()`, but automatically printed with `-h/--help` or on SystemExit from an ArgumentError):
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
$ python main.py --help
|
|
55
|
+
|
|
56
|
+
# Usage:
|
|
57
|
+
# main.py [OPTIONS] PATH
|
|
58
|
+
|
|
59
|
+
# Options:
|
|
60
|
+
# --help, -h
|
|
61
|
+
# Print this message and exit (default: False)
|
|
62
|
+
|
|
63
|
+
# --verbose
|
|
64
|
+
# enable verbose logging (default: False)
|
|
65
|
+
|
|
66
|
+
# -n, --notif, --send-notifications
|
|
67
|
+
# send all notifications (default: False)
|
|
68
|
+
|
|
69
|
+
# -L, --limit LIMIT <int>
|
|
70
|
+
# the max number of tries to try doing the thing (default: 10)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# Arguments:
|
|
74
|
+
# PATH <Path>
|
|
75
|
+
# the path to read
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`ArgSpec` (the class) is built on top of `dataclasses`, so you also get all of the dataclass functions (`__init__`, `__repr__`, etc.) for free:
|
|
79
|
+
|
|
80
|
+
```py
|
|
81
|
+
print(args) # Args(path=Path('/path/to/file'), limit=10, verbose=False, send_notifications=False)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Why not `argparse`?
|
|
85
|
+
|
|
86
|
+
`argparse` belongs to the standard library and is sufficient for most situations, but while it's capable, it's verbose through it's imperative style and does not allow for type inference and autocomplete.
|
|
87
|
+
|
|
88
|
+
```py
|
|
89
|
+
from argparse import ArgumentParser
|
|
90
|
+
from pathlib import Path
|
|
91
|
+
|
|
92
|
+
parser = ArgumentParser()
|
|
93
|
+
parser.add_argument("path", type=Path, help="the path to read")
|
|
94
|
+
parser.add_argument("-L", "--limit", type=int, default=10, help="the max number of times to try doing the thing")
|
|
95
|
+
parser.add_argument("-v", "--verbose", action="store_true", help="enable verbose logging")
|
|
96
|
+
parser.add_argument("-n", "--notif", "--send-notifications", action="store_true", help="send all notifications")
|
|
97
|
+
|
|
98
|
+
args = parser.parse_args()
|
|
99
|
+
print(args.notifications) # <-- AttributeError, but you don't get any help from your IDE
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
If you want type safety, you can do something like this:
|
|
103
|
+
|
|
104
|
+
```py
|
|
105
|
+
from argparse import ArgumentParser
|
|
106
|
+
from dataclasses import dataclass
|
|
107
|
+
from typing import Self
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class Args:
|
|
111
|
+
path: Path
|
|
112
|
+
limit: int
|
|
113
|
+
verbose: bool
|
|
114
|
+
send_notifications: bool
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@classmethod
|
|
118
|
+
def from_argv(cls) -> Self:
|
|
119
|
+
parser = ArgumentParser()
|
|
120
|
+
parser.add_argument("path", type=Path, help="the path to read")
|
|
121
|
+
parser.add_argument("-L", "--limit", type=int, default=10, help="the max number of times to try doing the thing")
|
|
122
|
+
parser.add_argument("-v", "--verbose", action="store_true", help="enable verbose logging")
|
|
123
|
+
parser.add_argument("-n", "--notif", "--send-notifications", action="store_true", help="send all notifications")
|
|
124
|
+
|
|
125
|
+
return cls(**vars(parser.parse_args()))
|
|
126
|
+
|
|
127
|
+
args = Args.from_argv()
|
|
128
|
+
print(args.send_notifications) # <-- You do get autocomplete for this
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
But, obviously, that's a pain, and you now have to define your arguments twice, which is a recipe for forgetting to update it in one of those places.
|
|
132
|
+
|
|
133
|
+
### Why not `typer`?
|
|
134
|
+
|
|
135
|
+
`typer` is probably the most popular argparse alternative, but it achieves its parsing goals by hijacking your function calls and injecting values into the signature. Plus, for basic uses, it's pretty clean, but for even just nontrivial examples...
|
|
136
|
+
|
|
137
|
+
```py
|
|
138
|
+
from pathlib import Path
|
|
139
|
+
from typing import Annotated
|
|
140
|
+
|
|
141
|
+
import typer
|
|
142
|
+
|
|
143
|
+
def main(
|
|
144
|
+
path: Annotated[Path, typer.Argument(help="the path to read")],
|
|
145
|
+
limit: Annotated[int, typer.Option("--limit", "-L", help="the max number of times...")] = 10,
|
|
146
|
+
verbose: Annotated[bool, typer.Option("--verbose", "-v", help="enable verbose logging")] = False,
|
|
147
|
+
send_notifications: Annotated[
|
|
148
|
+
bool,
|
|
149
|
+
typer.Option("--send-notifications", "--notif", "-n", help="send all notifications")
|
|
150
|
+
] = False,
|
|
151
|
+
):
|
|
152
|
+
print(f"Path: {path}, Limit: {limit}, Verbose: {verbose}")
|
|
153
|
+
|
|
154
|
+
if __name__ == "__main__":
|
|
155
|
+
typer.run(main)
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
That's certainly also pretty messy, what with all of the `typing.Annotated` calls everywhere and your function having a long signature with lots of parameters. You also don't get a consolidated `args` object this way, which may or may not be a benefit, depending on who you ask. (Personally, I want the consolidated object.)
|
|
159
|
+
|
|
160
|
+
That said, one thing `typer` is genuinely fantastic at is subcommands because it wants to use your functions anyway.
|
|
161
|
+
|
|
162
|
+
## Installation
|
|
163
|
+
|
|
164
|
+
`argspec` can be easily installed on any Python 3.10+ via a package manager, e.g.:
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
# using pip
|
|
168
|
+
$ pip install argspec
|
|
169
|
+
|
|
170
|
+
# using uv
|
|
171
|
+
$ uv add argspec
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
The only dependencies are [typewire](https://github.com/lilellia/typewire), a small bespoke library I wrote for handling the type conversions and posted independently, and `typing_extensions`.
|
|
175
|
+
|
|
176
|
+
## Documentation
|
|
177
|
+
|
|
178
|
+
### `ArgSpec`
|
|
179
|
+
|
|
180
|
+
Inherit from this class to get the argument parsing functionality. It converts your class to a dataclass and provides a `.from_argv` classmethod that will automatically interpret `sys.argv[1:]` (or you can provide it arguments directly) and give them back to you in their parsed form.
|
|
181
|
+
|
|
182
|
+
### `ArgumentError`, `ArgumentSpecError`
|
|
183
|
+
|
|
184
|
+
`ArgumentSpecError` is raised when there's an error with the specification itself. This could be because there are multiple arguments with the same name via aliases or because there are two positional arguments defined as variadic (which is disallowed because it leads to ambiguous and arbitrary parsing), or similar.
|
|
185
|
+
|
|
186
|
+
`ArgumentError` is raised once the parse is underway when something about the command line arguments that are passed in is invalid. Perhaps an argument is missing or there's an extra argument or it can't be converted to the correct type.
|
|
187
|
+
|
|
188
|
+
### `positional`, `option`, `flag`
|
|
189
|
+
|
|
190
|
+
Factory functions to define positional/option/flag argument interfaces. They take the following parameters:
|
|
191
|
+
|
|
192
|
+
| | | **positional** | **option** | **flag** |
|
|
193
|
+
|----------------------------------|-------------------------------------------------------------------------------|----------------|------------|------------|
|
|
194
|
+
| `default: T` | default value for the argument | ✓ | ✓ | ✓ (T=bool) |
|
|
195
|
+
| `validator: Callable[[T], bool]` | return True if the value is valid, False otherwise | ✓ | ✓ | ✕ |
|
|
196
|
+
| `aliases: Sequence[str]` | alternative names (long or short) for the option/flag | ✕ | ✓ | ✓ |
|
|
197
|
+
| `short: bool` | whether a short name should automatically be generated using the first letter | ✕ | ✓ | ✓ |
|
|
198
|
+
| `negators: Sequence[str]` | names for flags that can turn the flag "off" e.g., --no-verbose | ✕ | ✕ | ✓ |
|
|
199
|
+
| `help: str \| None` | the help text for the given argument | ✓ | ✓ | ✓ |
|
|
200
|
+
|
|
201
|
+
All of these parameters are optional, and all of them (except `default`) are keyword-only.
|
|
202
|
+
|
|
203
|
+
Notes:
|
|
204
|
+
|
|
205
|
+
- When `default` is unprovided for `positional` and `option`, it's interpreted as a missing value and must be filled in on the command line; for a flag, `default=False`.
|
|
206
|
+
- When using `short=True`, don't also manually provide the short name in `aliases` (such as `name: str = option(short=True, aliases=["-n"])`) as this will result in an ArgumentSpecError for having duplicate names.
|
|
207
|
+
- When a flag's default value is True, a negator is automatically generated. For example, `verbose: bool = flag(True)` generates `--no-verbose` as well.
|
|
208
|
+
|
|
209
|
+
### General Notes
|
|
210
|
+
|
|
211
|
+
#### `--key value` vs. `--key=value`
|
|
212
|
+
|
|
213
|
+
`argspec` allows for both formats for options. Flags, however, cannot take values even in the latter form. Thus, `--path /path/to/file` and `--path=/path/to/file` are both acceptable, but `--verbose=false` is not (use simply `--verbose` as an enable flag and `--no-verbose` as a disable flag).
|
|
214
|
+
|
|
215
|
+
#### Flexible Naming
|
|
216
|
+
|
|
217
|
+
`argspec` respects naming conventions. If you define a field as `some_variable`, it'll provide both `--some-variable` and `--some_variable` as valid options on the command line.
|
|
218
|
+
|
|
219
|
+
In addition, `-h/--help` are provided automatically, but they're not sacred. If you want to define `host: str = option(aliases=["-h"])`, then `argspec` will obey that, mapping `-h/--host` but will still provide `--help`.
|
|
220
|
+
|
|
221
|
+
#### Validators
|
|
222
|
+
|
|
223
|
+
`positional` and `option` both define a `validator` parameter. It should be a Callable that takes the desired argument type (not just the raw string value) and returns True if the value is valid and False otherwise. If False, an ArgumentError is raised during the parse.
|
|
224
|
+
|
|
225
|
+
```py
|
|
226
|
+
class Args(ArgSpec):
|
|
227
|
+
path: Path = positional(validator=lambda p: p.exists())
|
|
228
|
+
limit: int = option(validator=lambda limit: limit > 0)
|
|
229
|
+
|
|
230
|
+
# Since `Literal` cannot be dynamic, the validator can be used
|
|
231
|
+
# to implement choices in such cases where the values cannot be known in advance:
|
|
232
|
+
# mode: Literal["auto", "manual"] = option() # <-- prefer this one
|
|
233
|
+
mode: str = option(validator=lambda mode: mode in valid_mode_options)
|
|
234
|
+
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
#### Type Inference
|
|
238
|
+
|
|
239
|
+
`argspec` infers as much as it can from the type hints you give it.
|
|
240
|
+
|
|
241
|
+
```py
|
|
242
|
+
class Args(ArgSpec):
|
|
243
|
+
# --port PORT, required (because no default is provided), will be cast as int
|
|
244
|
+
port: int = option()
|
|
245
|
+
|
|
246
|
+
# --coordinate COORDINATE COORDINATE, required, will take two values, both cast as float
|
|
247
|
+
coordinate: tuple[float, float] = option()
|
|
248
|
+
|
|
249
|
+
# --mode MODE, not required (defaults to 'auto'), will only accept one of the given values
|
|
250
|
+
mode: Literal["auto", "manual", "magic"] = option("auto")
|
|
251
|
+
|
|
252
|
+
# --names [NAME ...], not required, will take as many values as it can
|
|
253
|
+
names: list[str] = option()
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
#### Look-Ahead Variadics
|
|
257
|
+
|
|
258
|
+
When defining variadic (variable-length) arguments, `argspec` will happily look ahead to see how many values it can safely take whilst still leaving enough for the later arguments. For example:
|
|
259
|
+
|
|
260
|
+
```py
|
|
261
|
+
class Args(ArgSpec):
|
|
262
|
+
head: str = positional()
|
|
263
|
+
middle: list[str] = positional()
|
|
264
|
+
penultimate: str = positional()
|
|
265
|
+
tail: str = positional()
|
|
266
|
+
and_two_more: tuple[str, str] = positional()
|
|
267
|
+
|
|
268
|
+
args = Args.from_argv(["A", "B", "C", "D", "E", "F", "G"])
|
|
269
|
+
print(args) # Args(head='A', middle=['B', 'C'], penultimate='D', tail='E', and_two_more=('F', 'G'))
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
However, this requires that *at most one* positional argument be defines as variadic. If multiple positionals are variadic, this is an ArgumentSpecError.
|
|
273
|
+
|
|
274
|
+
## Known Limitations
|
|
275
|
+
|
|
276
|
+
- `argspec` does not provide a mechanism for subcommands or argument groups (such as mutually exclusive arguments)
|
|
277
|
+
- `argspec` does not yet support combined short flags (i.e., `-a -b -c` cannot be shortened to `-abc)
|
argspec-0.3.0/README.md
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# argspec
|
|
2
|
+
|
|
3
|
+
A library for cleanly and succinctly performing type-safe command-line argument parsing via a declarative interface.
|
|
4
|
+
|
|
5
|
+
## Why `argspec`?
|
|
6
|
+
|
|
7
|
+
I view argument parsing as "the bit that happens before I can actually run my code". It's not part of my problem solving. It's literally just boilerplate to get information into my program so that my program can do its thing. As a result, I want it to be as minimal and as painless as possible. `argspec` aims to make it as invisible as possible without being magic.
|
|
8
|
+
|
|
9
|
+
```py
|
|
10
|
+
from argspec import ArgSpec, positional, option, flag
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
class Args(ArgSpec):
|
|
14
|
+
path: Path = positional(help="the path to read")
|
|
15
|
+
limit: int = option(10, aliases=["-L"], help="the max number of tries to try doing the thing")
|
|
16
|
+
verbose: bool = flag(short=True, help="enable verbose logging") # flags default to False, and short=True gives the -v alias
|
|
17
|
+
send_notifications: bool = flag(aliases=["-n", "--notif"], help="send all notifications")
|
|
18
|
+
|
|
19
|
+
args = Args.from_argv() # <-- .from_argv uses sys.argv[1:] by default, but you can provide a list manually if you want
|
|
20
|
+
print(args) # <-- an object with full type inference and autocomplete
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Of course, you also get a help message (accessible manually by `Args.__argspec_schema__.help()`, but automatically printed with `-h/--help` or on SystemExit from an ArgumentError):
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
$ python main.py --help
|
|
27
|
+
|
|
28
|
+
# Usage:
|
|
29
|
+
# main.py [OPTIONS] PATH
|
|
30
|
+
|
|
31
|
+
# Options:
|
|
32
|
+
# --help, -h
|
|
33
|
+
# Print this message and exit (default: False)
|
|
34
|
+
|
|
35
|
+
# --verbose
|
|
36
|
+
# enable verbose logging (default: False)
|
|
37
|
+
|
|
38
|
+
# -n, --notif, --send-notifications
|
|
39
|
+
# send all notifications (default: False)
|
|
40
|
+
|
|
41
|
+
# -L, --limit LIMIT <int>
|
|
42
|
+
# the max number of tries to try doing the thing (default: 10)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# Arguments:
|
|
46
|
+
# PATH <Path>
|
|
47
|
+
# the path to read
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`ArgSpec` (the class) is built on top of `dataclasses`, so you also get all of the dataclass functions (`__init__`, `__repr__`, etc.) for free:
|
|
51
|
+
|
|
52
|
+
```py
|
|
53
|
+
print(args) # Args(path=Path('/path/to/file'), limit=10, verbose=False, send_notifications=False)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Why not `argparse`?
|
|
57
|
+
|
|
58
|
+
`argparse` belongs to the standard library and is sufficient for most situations, but while it's capable, it's verbose through it's imperative style and does not allow for type inference and autocomplete.
|
|
59
|
+
|
|
60
|
+
```py
|
|
61
|
+
from argparse import ArgumentParser
|
|
62
|
+
from pathlib import Path
|
|
63
|
+
|
|
64
|
+
parser = ArgumentParser()
|
|
65
|
+
parser.add_argument("path", type=Path, help="the path to read")
|
|
66
|
+
parser.add_argument("-L", "--limit", type=int, default=10, help="the max number of times to try doing the thing")
|
|
67
|
+
parser.add_argument("-v", "--verbose", action="store_true", help="enable verbose logging")
|
|
68
|
+
parser.add_argument("-n", "--notif", "--send-notifications", action="store_true", help="send all notifications")
|
|
69
|
+
|
|
70
|
+
args = parser.parse_args()
|
|
71
|
+
print(args.notifications) # <-- AttributeError, but you don't get any help from your IDE
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
If you want type safety, you can do something like this:
|
|
75
|
+
|
|
76
|
+
```py
|
|
77
|
+
from argparse import ArgumentParser
|
|
78
|
+
from dataclasses import dataclass
|
|
79
|
+
from typing import Self
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class Args:
|
|
83
|
+
path: Path
|
|
84
|
+
limit: int
|
|
85
|
+
verbose: bool
|
|
86
|
+
send_notifications: bool
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
def from_argv(cls) -> Self:
|
|
91
|
+
parser = ArgumentParser()
|
|
92
|
+
parser.add_argument("path", type=Path, help="the path to read")
|
|
93
|
+
parser.add_argument("-L", "--limit", type=int, default=10, help="the max number of times to try doing the thing")
|
|
94
|
+
parser.add_argument("-v", "--verbose", action="store_true", help="enable verbose logging")
|
|
95
|
+
parser.add_argument("-n", "--notif", "--send-notifications", action="store_true", help="send all notifications")
|
|
96
|
+
|
|
97
|
+
return cls(**vars(parser.parse_args()))
|
|
98
|
+
|
|
99
|
+
args = Args.from_argv()
|
|
100
|
+
print(args.send_notifications) # <-- You do get autocomplete for this
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
But, obviously, that's a pain, and you now have to define your arguments twice, which is a recipe for forgetting to update it in one of those places.
|
|
104
|
+
|
|
105
|
+
### Why not `typer`?
|
|
106
|
+
|
|
107
|
+
`typer` is probably the most popular argparse alternative, but it achieves its parsing goals by hijacking your function calls and injecting values into the signature. Plus, for basic uses, it's pretty clean, but for even just nontrivial examples...
|
|
108
|
+
|
|
109
|
+
```py
|
|
110
|
+
from pathlib import Path
|
|
111
|
+
from typing import Annotated
|
|
112
|
+
|
|
113
|
+
import typer
|
|
114
|
+
|
|
115
|
+
def main(
|
|
116
|
+
path: Annotated[Path, typer.Argument(help="the path to read")],
|
|
117
|
+
limit: Annotated[int, typer.Option("--limit", "-L", help="the max number of times...")] = 10,
|
|
118
|
+
verbose: Annotated[bool, typer.Option("--verbose", "-v", help="enable verbose logging")] = False,
|
|
119
|
+
send_notifications: Annotated[
|
|
120
|
+
bool,
|
|
121
|
+
typer.Option("--send-notifications", "--notif", "-n", help="send all notifications")
|
|
122
|
+
] = False,
|
|
123
|
+
):
|
|
124
|
+
print(f"Path: {path}, Limit: {limit}, Verbose: {verbose}")
|
|
125
|
+
|
|
126
|
+
if __name__ == "__main__":
|
|
127
|
+
typer.run(main)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
That's certainly also pretty messy, what with all of the `typing.Annotated` calls everywhere and your function having a long signature with lots of parameters. You also don't get a consolidated `args` object this way, which may or may not be a benefit, depending on who you ask. (Personally, I want the consolidated object.)
|
|
131
|
+
|
|
132
|
+
That said, one thing `typer` is genuinely fantastic at is subcommands because it wants to use your functions anyway.
|
|
133
|
+
|
|
134
|
+
## Installation
|
|
135
|
+
|
|
136
|
+
`argspec` can be easily installed on any Python 3.10+ via a package manager, e.g.:
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
# using pip
|
|
140
|
+
$ pip install argspec
|
|
141
|
+
|
|
142
|
+
# using uv
|
|
143
|
+
$ uv add argspec
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
The only dependencies are [typewire](https://github.com/lilellia/typewire), a small bespoke library I wrote for handling the type conversions and posted independently, and `typing_extensions`.
|
|
147
|
+
|
|
148
|
+
## Documentation
|
|
149
|
+
|
|
150
|
+
### `ArgSpec`
|
|
151
|
+
|
|
152
|
+
Inherit from this class to get the argument parsing functionality. It converts your class to a dataclass and provides a `.from_argv` classmethod that will automatically interpret `sys.argv[1:]` (or you can provide it arguments directly) and give them back to you in their parsed form.
|
|
153
|
+
|
|
154
|
+
### `ArgumentError`, `ArgumentSpecError`
|
|
155
|
+
|
|
156
|
+
`ArgumentSpecError` is raised when there's an error with the specification itself. This could be because there are multiple arguments with the same name via aliases or because there are two positional arguments defined as variadic (which is disallowed because it leads to ambiguous and arbitrary parsing), or similar.
|
|
157
|
+
|
|
158
|
+
`ArgumentError` is raised once the parse is underway when something about the command line arguments that are passed in is invalid. Perhaps an argument is missing or there's an extra argument or it can't be converted to the correct type.
|
|
159
|
+
|
|
160
|
+
### `positional`, `option`, `flag`
|
|
161
|
+
|
|
162
|
+
Factory functions to define positional/option/flag argument interfaces. They take the following parameters:
|
|
163
|
+
|
|
164
|
+
| | | **positional** | **option** | **flag** |
|
|
165
|
+
|----------------------------------|-------------------------------------------------------------------------------|----------------|------------|------------|
|
|
166
|
+
| `default: T` | default value for the argument | ✓ | ✓ | ✓ (T=bool) |
|
|
167
|
+
| `validator: Callable[[T], bool]` | return True if the value is valid, False otherwise | ✓ | ✓ | ✕ |
|
|
168
|
+
| `aliases: Sequence[str]` | alternative names (long or short) for the option/flag | ✕ | ✓ | ✓ |
|
|
169
|
+
| `short: bool` | whether a short name should automatically be generated using the first letter | ✕ | ✓ | ✓ |
|
|
170
|
+
| `negators: Sequence[str]` | names for flags that can turn the flag "off" e.g., --no-verbose | ✕ | ✕ | ✓ |
|
|
171
|
+
| `help: str \| None` | the help text for the given argument | ✓ | ✓ | ✓ |
|
|
172
|
+
|
|
173
|
+
All of these parameters are optional, and all of them (except `default`) are keyword-only.
|
|
174
|
+
|
|
175
|
+
Notes:
|
|
176
|
+
|
|
177
|
+
- When `default` is unprovided for `positional` and `option`, it's interpreted as a missing value and must be filled in on the command line; for a flag, `default=False`.
|
|
178
|
+
- When using `short=True`, don't also manually provide the short name in `aliases` (such as `name: str = option(short=True, aliases=["-n"])`) as this will result in an ArgumentSpecError for having duplicate names.
|
|
179
|
+
- When a flag's default value is True, a negator is automatically generated. For example, `verbose: bool = flag(True)` generates `--no-verbose` as well.
|
|
180
|
+
|
|
181
|
+
### General Notes
|
|
182
|
+
|
|
183
|
+
#### `--key value` vs. `--key=value`
|
|
184
|
+
|
|
185
|
+
`argspec` allows for both formats for options. Flags, however, cannot take values even in the latter form. Thus, `--path /path/to/file` and `--path=/path/to/file` are both acceptable, but `--verbose=false` is not (use simply `--verbose` as an enable flag and `--no-verbose` as a disable flag).
|
|
186
|
+
|
|
187
|
+
#### Flexible Naming
|
|
188
|
+
|
|
189
|
+
`argspec` respects naming conventions. If you define a field as `some_variable`, it'll provide both `--some-variable` and `--some_variable` as valid options on the command line.
|
|
190
|
+
|
|
191
|
+
In addition, `-h/--help` are provided automatically, but they're not sacred. If you want to define `host: str = option(aliases=["-h"])`, then `argspec` will obey that, mapping `-h/--host` but will still provide `--help`.
|
|
192
|
+
|
|
193
|
+
#### Validators
|
|
194
|
+
|
|
195
|
+
`positional` and `option` both define a `validator` parameter. It should be a Callable that takes the desired argument type (not just the raw string value) and returns True if the value is valid and False otherwise. If False, an ArgumentError is raised during the parse.
|
|
196
|
+
|
|
197
|
+
```py
|
|
198
|
+
class Args(ArgSpec):
|
|
199
|
+
path: Path = positional(validator=lambda p: p.exists())
|
|
200
|
+
limit: int = option(validator=lambda limit: limit > 0)
|
|
201
|
+
|
|
202
|
+
# Since `Literal` cannot be dynamic, the validator can be used
|
|
203
|
+
# to implement choices in such cases where the values cannot be known in advance:
|
|
204
|
+
# mode: Literal["auto", "manual"] = option() # <-- prefer this one
|
|
205
|
+
mode: str = option(validator=lambda mode: mode in valid_mode_options)
|
|
206
|
+
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
#### Type Inference
|
|
210
|
+
|
|
211
|
+
`argspec` infers as much as it can from the type hints you give it.
|
|
212
|
+
|
|
213
|
+
```py
|
|
214
|
+
class Args(ArgSpec):
|
|
215
|
+
# --port PORT, required (because no default is provided), will be cast as int
|
|
216
|
+
port: int = option()
|
|
217
|
+
|
|
218
|
+
# --coordinate COORDINATE COORDINATE, required, will take two values, both cast as float
|
|
219
|
+
coordinate: tuple[float, float] = option()
|
|
220
|
+
|
|
221
|
+
# --mode MODE, not required (defaults to 'auto'), will only accept one of the given values
|
|
222
|
+
mode: Literal["auto", "manual", "magic"] = option("auto")
|
|
223
|
+
|
|
224
|
+
# --names [NAME ...], not required, will take as many values as it can
|
|
225
|
+
names: list[str] = option()
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
#### Look-Ahead Variadics
|
|
229
|
+
|
|
230
|
+
When defining variadic (variable-length) arguments, `argspec` will happily look ahead to see how many values it can safely take whilst still leaving enough for the later arguments. For example:
|
|
231
|
+
|
|
232
|
+
```py
|
|
233
|
+
class Args(ArgSpec):
|
|
234
|
+
head: str = positional()
|
|
235
|
+
middle: list[str] = positional()
|
|
236
|
+
penultimate: str = positional()
|
|
237
|
+
tail: str = positional()
|
|
238
|
+
and_two_more: tuple[str, str] = positional()
|
|
239
|
+
|
|
240
|
+
args = Args.from_argv(["A", "B", "C", "D", "E", "F", "G"])
|
|
241
|
+
print(args) # Args(head='A', middle=['B', 'C'], penultimate='D', tail='E', and_two_more=('F', 'G'))
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
However, this requires that *at most one* positional argument be defines as variadic. If multiple positionals are variadic, this is an ArgumentSpecError.
|
|
245
|
+
|
|
246
|
+
## Known Limitations
|
|
247
|
+
|
|
248
|
+
- `argspec` does not provide a mechanism for subcommands or argument groups (such as mutually exclusive arguments)
|
|
249
|
+
- `argspec` does not yet support combined short flags (i.e., `-a -b -c` cannot be shortened to `-abc)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from collections.abc import Sequence
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
import sys
|
|
4
|
+
from typing import Any, cast, dataclass_transform, Self
|
|
5
|
+
|
|
6
|
+
from .parse import ArgumentError, Schema
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass_transform()
|
|
10
|
+
class ArgSpecMeta(type):
|
|
11
|
+
__argspec_schema__: Schema
|
|
12
|
+
|
|
13
|
+
def __new__(mcs, name: str, bases: tuple[type, ...], namespace: dict[str, Any], **kwargs: Any) -> type:
|
|
14
|
+
cls = super().__new__(mcs, name, bases, namespace, **kwargs)
|
|
15
|
+
|
|
16
|
+
if name == "ArgSpec":
|
|
17
|
+
return cls
|
|
18
|
+
|
|
19
|
+
cls = cast(Any, dataclass(cls))
|
|
20
|
+
cls.__argspec_schema__ = Schema.for_class(cls)
|
|
21
|
+
|
|
22
|
+
return cast(type, cls)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ArgSpec(metaclass=ArgSpecMeta):
|
|
26
|
+
@classmethod
|
|
27
|
+
def __help(cls) -> str:
|
|
28
|
+
return cls.__argspec_schema__.help()
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def _from_argv(cls, argv: Sequence[str] | None = None) -> Self:
|
|
32
|
+
"""Parse the given argv (or sys.argv[1:]) into an instance of the class."""
|
|
33
|
+
kwargs = cls.__argspec_schema__.parse_args(argv)
|
|
34
|
+
return cls(**kwargs)
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def from_argv(cls, argv: Sequence[str] | None = None) -> Self:
|
|
38
|
+
"""Parse the given argv (or sys.argv[1:]) into an instance of the class."""
|
|
39
|
+
try:
|
|
40
|
+
return cls._from_argv(argv)
|
|
41
|
+
except ArgumentError as err:
|
|
42
|
+
sys.stderr.write(f"ArgumentError: {err}\n")
|
|
43
|
+
sys.stderr.write(cls.__help() + "\n")
|
|
44
|
+
sys.exit(1)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from collections.abc import Callable, Sequence
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from typing import Any, Generic, TypeVar
|
|
4
|
+
|
|
5
|
+
T = TypeVar("T")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _true(_: T, /) -> bool:
|
|
9
|
+
return True
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MissingType:
|
|
13
|
+
def __repr__(self) -> str:
|
|
14
|
+
return "MISSING"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
MISSING = MissingType()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class Positional(Generic[T]):
|
|
22
|
+
default: T | MissingType = MISSING
|
|
23
|
+
validator: Callable[[T], bool] = _true
|
|
24
|
+
help: str | None = None
|
|
25
|
+
|
|
26
|
+
def is_required(self) -> bool:
|
|
27
|
+
return self.default is MISSING
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class Option(Generic[T]):
|
|
32
|
+
default: T | MissingType = MISSING
|
|
33
|
+
short: bool = False
|
|
34
|
+
aliases: Sequence[str] | None = field(default_factory=list)
|
|
35
|
+
validator: Callable[[T], bool] = _true
|
|
36
|
+
help: str | None = None
|
|
37
|
+
|
|
38
|
+
def is_required(self) -> bool:
|
|
39
|
+
return self.default is MISSING
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, slots=True)
|
|
43
|
+
class Flag:
|
|
44
|
+
default: bool = False
|
|
45
|
+
short: bool = False
|
|
46
|
+
aliases: Sequence[str] | None = field(default_factory=list)
|
|
47
|
+
negators: Sequence[str] | None = field(default_factory=list)
|
|
48
|
+
help: str | None = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# The positional, option, flag functions are typed to return Any, rather than the actual dataclass types (above)
|
|
52
|
+
# that they return so that they can be used as the values in the dataclass fields. That is,
|
|
53
|
+
# port: int = option(8080, aliases=("-p",), help="The port to listen on")
|
|
54
|
+
# should succeed, but type checkers will (correctly) realise that the RHS is not an int. But typing the function
|
|
55
|
+
# as -> Any will bypass the typing check.
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def positional(
|
|
59
|
+
default: T | MissingType = MISSING,
|
|
60
|
+
*,
|
|
61
|
+
validator: Callable[[T], bool] = _true,
|
|
62
|
+
help: str | None = None,
|
|
63
|
+
) -> Any:
|
|
64
|
+
return Positional(default=default, validator=validator, help=help)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def option(
|
|
68
|
+
default: T | MissingType = MISSING,
|
|
69
|
+
*,
|
|
70
|
+
short: bool = False,
|
|
71
|
+
aliases: Sequence[str] | None = None,
|
|
72
|
+
validator: Callable[[T], bool] = _true,
|
|
73
|
+
help: str | None = None,
|
|
74
|
+
) -> Any:
|
|
75
|
+
return Option(default=default, short=short, aliases=aliases, validator=validator, help=help)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def flag(
|
|
79
|
+
default: bool = False,
|
|
80
|
+
*,
|
|
81
|
+
short: bool = False,
|
|
82
|
+
aliases: Sequence[str] | None = None,
|
|
83
|
+
negators: Sequence[str] | None = None,
|
|
84
|
+
help: str | None = None,
|
|
85
|
+
) -> Any:
|
|
86
|
+
return Flag(default=default, short=short, aliases=aliases, negators=negators, help=help)
|
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
from collections import deque
|
|
2
|
+
from collections.abc import Sequence
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from io import StringIO
|
|
5
|
+
import itertools
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import sys
|
|
8
|
+
from typing import Any, cast, get_args, get_origin, NamedTuple, Self, TypeVar
|
|
9
|
+
|
|
10
|
+
from typewire import as_type, is_iterable, TypeHint
|
|
11
|
+
from typing_extensions import get_annotations
|
|
12
|
+
|
|
13
|
+
from .metadata import _true, Flag, MISSING, Option, Positional
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ArgumentError(Exception):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ArgumentSpecError(Exception):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ArgvConsumeResult(NamedTuple):
|
|
25
|
+
parsed_args: dict[str, Any]
|
|
26
|
+
positional_args: deque[str]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
C = TypeVar("C")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_container_length(type_hint: TypeHint) -> int | None:
|
|
33
|
+
"""Get the length of a container type. Return 0 for noncontainers, None for containers of unknown length.
|
|
34
|
+
|
|
35
|
+
>>> get_container_length(int)
|
|
36
|
+
0
|
|
37
|
+
|
|
38
|
+
>>> get_container_length(list[int]) # arbitrary length
|
|
39
|
+
None
|
|
40
|
+
|
|
41
|
+
>>> get_container_length(tuple[int, str])
|
|
42
|
+
2
|
|
43
|
+
|
|
44
|
+
>>> get_container_length(tuple[int, ...])
|
|
45
|
+
None
|
|
46
|
+
"""
|
|
47
|
+
if not is_iterable(type_hint):
|
|
48
|
+
return 0
|
|
49
|
+
|
|
50
|
+
if type_hint in (str, bytes):
|
|
51
|
+
# specifically handle Iterable[str] and Iterable[bytes] as simply str and bytes
|
|
52
|
+
return 0
|
|
53
|
+
|
|
54
|
+
args = get_args(type_hint)
|
|
55
|
+
origin = get_origin(type_hint)
|
|
56
|
+
|
|
57
|
+
# if tuple[T, T] fixed length
|
|
58
|
+
if cast(Any, origin) is tuple:
|
|
59
|
+
if not args:
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
return None if Ellipsis in args else len(args)
|
|
63
|
+
|
|
64
|
+
# otherwise, it's a variadic container
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def kebabify(text: str, *, lower: bool = False) -> str:
|
|
69
|
+
kebab = text.replace("_", "-")
|
|
70
|
+
return kebab.lower() if lower else kebab
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def format_help_message_for_positional(name: str, type_: TypeHint, meta: Positional[Any]) -> str:
|
|
74
|
+
match get_container_length(type_):
|
|
75
|
+
case 0:
|
|
76
|
+
value = name.upper()
|
|
77
|
+
case None:
|
|
78
|
+
value = f"{name.upper()} [{name.upper()}...]"
|
|
79
|
+
case n:
|
|
80
|
+
value = " ".join([name.upper() for _ in range(n)])
|
|
81
|
+
|
|
82
|
+
return value if meta.is_required() else f"[{value}]"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True, slots=True)
|
|
86
|
+
class Schema:
|
|
87
|
+
args: dict[str, tuple[TypeHint, Positional[Any] | Option[Any] | Flag]]
|
|
88
|
+
aliases: dict[str, str]
|
|
89
|
+
flag_negators: dict[str, str]
|
|
90
|
+
|
|
91
|
+
def __post_init__(self) -> None:
|
|
92
|
+
arities = [self.nargs_for(name) for name in self.positional_args.keys()]
|
|
93
|
+
if arities.count(None) > 1:
|
|
94
|
+
raise ArgumentSpecError("Multiple positional arguments with arbitrary length")
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def positional_args(self) -> dict[str, tuple[TypeHint, Positional[Any]]]:
|
|
98
|
+
return {name: (type_, meta) for name, (type_, meta) in self.args.items() if isinstance(meta, Positional)}
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def option_args(self) -> dict[str, tuple[TypeHint, Option[Any]]]:
|
|
102
|
+
return {name: (type_, meta) for name, (type_, meta) in self.args.items() if isinstance(meta, Option)}
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def flag_args(self) -> dict[str, tuple[TypeHint, Flag]]:
|
|
106
|
+
return {name: (type_, meta) for name, (type_, meta) in self.args.items() if isinstance(meta, Flag)}
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def help_keys(self) -> list[str]:
|
|
110
|
+
return [k for k in ("-h", "--help") if k not in {**self.args, **self.aliases}.keys()]
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def named_tokens(self) -> set[str]:
|
|
114
|
+
return set(self.aliases.keys()) | set(self.flag_negators.keys())
|
|
115
|
+
|
|
116
|
+
@staticmethod
|
|
117
|
+
def make_short(name: str) -> str:
|
|
118
|
+
return f"-{name.lstrip('-')[0]}"
|
|
119
|
+
|
|
120
|
+
def is_flag(self, token: str) -> bool:
|
|
121
|
+
token = self.aliases.get(token, token)
|
|
122
|
+
|
|
123
|
+
if token.lstrip("-") in (*self.flag_args.keys(), *self.flag_negators.keys()):
|
|
124
|
+
return True
|
|
125
|
+
|
|
126
|
+
if any(meta.short and token == self.make_short(name) for name, (_, meta) in self.flag_args.items()):
|
|
127
|
+
return True
|
|
128
|
+
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
def nargs_for(self, name: str) -> int | None:
|
|
132
|
+
type_, _ = self.args[name]
|
|
133
|
+
return get_container_length(type_)
|
|
134
|
+
|
|
135
|
+
@classmethod
|
|
136
|
+
def for_class(cls, wrapped_cls: type[C]) -> Self:
|
|
137
|
+
args: dict[str, tuple[TypeHint, Positional[Any] | Option[Any] | Flag]] = {}
|
|
138
|
+
aliases: dict[str, str] = {}
|
|
139
|
+
flag_negators: dict[str, str] = {}
|
|
140
|
+
for name, annot in get_annotations(wrapped_cls, eval_str=True).items():
|
|
141
|
+
value = getattr(wrapped_cls, name)
|
|
142
|
+
args[name] = (annot, value)
|
|
143
|
+
kebab_name = kebabify(name, lower=True)
|
|
144
|
+
|
|
145
|
+
if isinstance(value, (Option, Flag)):
|
|
146
|
+
if f"--{kebab_name}" in aliases:
|
|
147
|
+
raise ArgumentSpecError(f"Duplicate option: --{name}")
|
|
148
|
+
|
|
149
|
+
aliases[f"--{kebab_name}"] = name
|
|
150
|
+
|
|
151
|
+
if name != kebab_name:
|
|
152
|
+
if f"--{name}" in aliases:
|
|
153
|
+
raise ArgumentSpecError(f"Duplicate option: --{name}")
|
|
154
|
+
|
|
155
|
+
aliases[f"--{name}"] = name
|
|
156
|
+
|
|
157
|
+
for alias in value.aliases or []:
|
|
158
|
+
if alias in aliases:
|
|
159
|
+
raise ArgumentSpecError(f"Duplicate option alias: {alias}")
|
|
160
|
+
aliases[alias] = name
|
|
161
|
+
|
|
162
|
+
if value.short:
|
|
163
|
+
if (short := cls.make_short(name)) in aliases:
|
|
164
|
+
raise ArgumentSpecError(f"Duplicate option alias: {short}")
|
|
165
|
+
aliases[short] = name
|
|
166
|
+
|
|
167
|
+
# flag negators
|
|
168
|
+
if isinstance(value, Flag):
|
|
169
|
+
for negator in value.negators or []:
|
|
170
|
+
if negator in (*aliases.keys(), *flag_negators.keys()):
|
|
171
|
+
raise ArgumentSpecError(f"Duplicate flag negator: {negator}")
|
|
172
|
+
|
|
173
|
+
flag_negators[negator] = name
|
|
174
|
+
|
|
175
|
+
# provide a default negator for flags that default to True when no other negator is provided
|
|
176
|
+
if value.default is True and not value.negators and (negator := f"--no-{kebab_name}") not in aliases:
|
|
177
|
+
flag_negators[negator] = name
|
|
178
|
+
|
|
179
|
+
return cls(args=args, aliases=aliases, flag_negators=flag_negators)
|
|
180
|
+
|
|
181
|
+
def get_all_names_for(self, name: str, meta: Option | Flag) -> list[str]:
|
|
182
|
+
names = [kebabify(name if name.startswith("-") else f"--{name}", lower=True)]
|
|
183
|
+
|
|
184
|
+
if meta.aliases:
|
|
185
|
+
names = [*meta.aliases, *names]
|
|
186
|
+
|
|
187
|
+
if meta.short:
|
|
188
|
+
names = [f"-{name[0]}", *names]
|
|
189
|
+
|
|
190
|
+
return names
|
|
191
|
+
|
|
192
|
+
def help(self) -> str:
|
|
193
|
+
"""Return a help string for the given argument specification schema."""
|
|
194
|
+
buffer = StringIO()
|
|
195
|
+
|
|
196
|
+
buffer.write("Usage:\n")
|
|
197
|
+
positionals = " ".join(
|
|
198
|
+
format_help_message_for_positional(name, type_, meta)
|
|
199
|
+
for name, (type_, meta) in self.positional_args.items()
|
|
200
|
+
)
|
|
201
|
+
prog = Path(sys.argv[0]).name
|
|
202
|
+
|
|
203
|
+
buffer.write(f" {prog} [OPTIONS] {positionals}\n\n")
|
|
204
|
+
buffer.write("Options:\n")
|
|
205
|
+
|
|
206
|
+
# flags
|
|
207
|
+
if self.help_keys:
|
|
208
|
+
help_ = {self.help_keys[0]: (bool, Flag(aliases=self.help_keys[1:], help="Print this message and exit"))}
|
|
209
|
+
else:
|
|
210
|
+
help_ = {}
|
|
211
|
+
|
|
212
|
+
meta: Flag | Option[Any] | Positional[Any]
|
|
213
|
+
|
|
214
|
+
for name, (type_, meta) in {**help_, **self.flag_args}.items():
|
|
215
|
+
names = ", ".join(self.get_all_names_for(name, meta))
|
|
216
|
+
|
|
217
|
+
type_name = type_.__name__ if hasattr(type_, "__name__") else str(type_)
|
|
218
|
+
buffer.write(f" true: {names}\n")
|
|
219
|
+
|
|
220
|
+
if negators := {k for k, v in self.flag_negators.items() if v == name}:
|
|
221
|
+
buffer.write(f" false: {', '.join(negators)}\n")
|
|
222
|
+
|
|
223
|
+
buffer.write(f" {meta.help or ''}")
|
|
224
|
+
buffer.write(f" (default: {meta.default})")
|
|
225
|
+
|
|
226
|
+
buffer.write("\n\n")
|
|
227
|
+
|
|
228
|
+
# values
|
|
229
|
+
for name, (type_, meta) in self.option_args.items():
|
|
230
|
+
names = ", ".join(self.get_all_names_for(name, meta))
|
|
231
|
+
|
|
232
|
+
type_name = type_.__name__ if hasattr(type_, "__name__") else str(type_)
|
|
233
|
+
buffer.write(f" {names} {name.upper()} <{type_name}>\n")
|
|
234
|
+
buffer.write(f" {meta.help or ''}")
|
|
235
|
+
|
|
236
|
+
if meta.default is not MISSING:
|
|
237
|
+
buffer.write(f" (default: {meta.default})")
|
|
238
|
+
|
|
239
|
+
buffer.write("\n\n")
|
|
240
|
+
|
|
241
|
+
# positional arguments
|
|
242
|
+
buffer.write("\nArguments:\n")
|
|
243
|
+
for name, (type_, meta) in self.positional_args.items():
|
|
244
|
+
type_name = type_.__name__ if hasattr(type_, "__name__") else str(type_)
|
|
245
|
+
buffer.write(f" {kebabify(name.upper())} <{type_name}>\n")
|
|
246
|
+
buffer.write(f" {meta.help or ''}")
|
|
247
|
+
|
|
248
|
+
if meta.default is not MISSING:
|
|
249
|
+
buffer.write(f" (default: {meta.default})")
|
|
250
|
+
|
|
251
|
+
buffer.write("\n\n")
|
|
252
|
+
|
|
253
|
+
return buffer.getvalue()
|
|
254
|
+
|
|
255
|
+
def pop_until_next_token_or_limit(self, pool: deque[str], name: str, arity: int | None) -> list[str]:
|
|
256
|
+
tokens: list[str] = []
|
|
257
|
+
|
|
258
|
+
for taken in itertools.count():
|
|
259
|
+
if arity is not None and taken >= arity:
|
|
260
|
+
# we've hit the arity limit
|
|
261
|
+
break
|
|
262
|
+
|
|
263
|
+
if not pool:
|
|
264
|
+
# we've run out of tokens to take
|
|
265
|
+
if arity is None:
|
|
266
|
+
# this is fine, because we were just picking until we ran out
|
|
267
|
+
break
|
|
268
|
+
|
|
269
|
+
raise ArgumentError(f"Missing value for option --{name}")
|
|
270
|
+
|
|
271
|
+
val = pool.popleft()
|
|
272
|
+
if val in self.named_tokens:
|
|
273
|
+
# this is another token, so put it back
|
|
274
|
+
pool.appendleft(val)
|
|
275
|
+
break
|
|
276
|
+
|
|
277
|
+
if val == "--":
|
|
278
|
+
# we've hit the end of the available tokens
|
|
279
|
+
break
|
|
280
|
+
|
|
281
|
+
tokens.append(val)
|
|
282
|
+
|
|
283
|
+
return tokens
|
|
284
|
+
|
|
285
|
+
def consume_argv(self, argv: deque[str]) -> ArgvConsumeResult:
|
|
286
|
+
parsed_args: dict[str, Any] = {}
|
|
287
|
+
positional_args: deque[str] = deque()
|
|
288
|
+
|
|
289
|
+
while argv:
|
|
290
|
+
token = argv.popleft()
|
|
291
|
+
|
|
292
|
+
if token == "--":
|
|
293
|
+
positional_args.extend(argv)
|
|
294
|
+
break
|
|
295
|
+
|
|
296
|
+
if token.startswith("-") and "=" in token:
|
|
297
|
+
# allow `--key=value` to be interpreted as `--key value`
|
|
298
|
+
# by stripping out the value and just adding it back into the pool
|
|
299
|
+
token, val = token.split("=", maxsplit=1)
|
|
300
|
+
|
|
301
|
+
if self.is_flag(token):
|
|
302
|
+
raise ArgumentError(f"Flag {token} does not take a value (`{token}={val}`)")
|
|
303
|
+
|
|
304
|
+
argv.appendleft(val)
|
|
305
|
+
|
|
306
|
+
if token not in self.named_tokens:
|
|
307
|
+
positional_args.append(token)
|
|
308
|
+
continue
|
|
309
|
+
|
|
310
|
+
if token in self.flag_negators:
|
|
311
|
+
name = self.flag_negators[token]
|
|
312
|
+
parsed_args[name] = False
|
|
313
|
+
continue
|
|
314
|
+
|
|
315
|
+
name = self.aliases[token]
|
|
316
|
+
type_, meta = self.args[name]
|
|
317
|
+
|
|
318
|
+
if isinstance(meta, Option):
|
|
319
|
+
try:
|
|
320
|
+
value = (
|
|
321
|
+
self.pop_until_next_token_or_limit(argv, name, arity=self.nargs_for(name))
|
|
322
|
+
if is_iterable(type_)
|
|
323
|
+
else argv.popleft()
|
|
324
|
+
)
|
|
325
|
+
except IndexError:
|
|
326
|
+
raise ArgumentError(f"Missing value for option --{name}")
|
|
327
|
+
except ArgumentError:
|
|
328
|
+
raise
|
|
329
|
+
|
|
330
|
+
try:
|
|
331
|
+
parsed_args[name] = as_type(value, type_)
|
|
332
|
+
except ValueError as err:
|
|
333
|
+
raise ArgumentError(f"Invalid value for option --{name}: {value} ({err})")
|
|
334
|
+
|
|
335
|
+
elif isinstance(meta, Flag):
|
|
336
|
+
parsed_args[name] = True
|
|
337
|
+
|
|
338
|
+
else:
|
|
339
|
+
raise ArgumentError(f"Unknown argument: {token}")
|
|
340
|
+
|
|
341
|
+
return ArgvConsumeResult(parsed_args, positional_args)
|
|
342
|
+
|
|
343
|
+
def required_positionals_after(self, name: str) -> int:
|
|
344
|
+
names = list(self.positional_args.keys())
|
|
345
|
+
index = names.index(name)
|
|
346
|
+
|
|
347
|
+
nargs = [cast(int, self.nargs_for(arg)) for arg in names[index + 1 :]]
|
|
348
|
+
return sum(max(1, n) for n in nargs)
|
|
349
|
+
|
|
350
|
+
def assign_positional_arg(self, name: str, positional_args: deque[str]) -> Any:
|
|
351
|
+
type_, meta = self.args[name]
|
|
352
|
+
|
|
353
|
+
if not positional_args:
|
|
354
|
+
if meta.default is not MISSING:
|
|
355
|
+
return meta.default
|
|
356
|
+
|
|
357
|
+
if is_iterable(type_):
|
|
358
|
+
return []
|
|
359
|
+
|
|
360
|
+
raise ArgumentError(f"Missing positional argument: {name}")
|
|
361
|
+
|
|
362
|
+
if not is_iterable(type_):
|
|
363
|
+
value = positional_args.popleft()
|
|
364
|
+
try:
|
|
365
|
+
return as_type(value, type_)
|
|
366
|
+
except ValueError as err:
|
|
367
|
+
raise ArgumentError(f"Invalid value for positional argument {name}: {value} ({err})")
|
|
368
|
+
|
|
369
|
+
collected = []
|
|
370
|
+
|
|
371
|
+
if (arity := self.nargs_for(name)) is None:
|
|
372
|
+
# consume as much as possible
|
|
373
|
+
arity = len(positional_args) - self.required_positionals_after(name)
|
|
374
|
+
|
|
375
|
+
for _ in range(arity):
|
|
376
|
+
try:
|
|
377
|
+
value = positional_args.popleft()
|
|
378
|
+
except IndexError:
|
|
379
|
+
raise ArgumentError(f"Missing value for positional argument {name}")
|
|
380
|
+
collected.append(value)
|
|
381
|
+
|
|
382
|
+
if not collected and meta.default is not MISSING:
|
|
383
|
+
return meta.default
|
|
384
|
+
|
|
385
|
+
try:
|
|
386
|
+
return as_type(collected, type_)
|
|
387
|
+
except ValueError as err:
|
|
388
|
+
raise ArgumentError(f"Invalid value for positional argument {name}: {collected} ({err})")
|
|
389
|
+
|
|
390
|
+
def assign_positional_args(self, parsed_args: dict[str, Any], positional_args: deque[str]) -> None:
|
|
391
|
+
"""Update the parsed_args dict by assigning the positional arguments according to the schema."""
|
|
392
|
+
for name in self.positional_args.keys():
|
|
393
|
+
parsed_args[name] = self.assign_positional_arg(name, positional_args)
|
|
394
|
+
|
|
395
|
+
def raise_if_extra_positional_args(self, positional_args: deque[str]) -> None:
|
|
396
|
+
if positional_args:
|
|
397
|
+
raise ArgumentError(f"Too many positional arguments: {', '.join(positional_args)}")
|
|
398
|
+
|
|
399
|
+
def apply_defaults(self, parsed_args: dict[str, Any]) -> None:
|
|
400
|
+
for name, (type_, meta) in self.args.items():
|
|
401
|
+
value = parsed_args.get(name, meta)
|
|
402
|
+
|
|
403
|
+
if isinstance(value, (Option, Flag)):
|
|
404
|
+
if value.default is MISSING:
|
|
405
|
+
raise ArgumentError(f"Missing value for: --{name}")
|
|
406
|
+
|
|
407
|
+
parsed_args[name] = as_type(value.default, type_)
|
|
408
|
+
|
|
409
|
+
def run_validators(self, parsed_args: dict[str, Any]) -> None:
|
|
410
|
+
for name, (type_, meta) in self.args.items():
|
|
411
|
+
value = parsed_args[name]
|
|
412
|
+
validator = getattr(meta, "validator", _true)
|
|
413
|
+
|
|
414
|
+
if not validator(value):
|
|
415
|
+
raise ArgumentError(f"Invalid value for {name}: {value}")
|
|
416
|
+
|
|
417
|
+
def parse_args(self, argv: Sequence[str] | None = None) -> dict[str, Any]:
|
|
418
|
+
"""Parse the given argv (or sys.argv[1:]) into a dict according to the schema."""
|
|
419
|
+
argv = deque(argv if argv is not None else sys.argv[1:])
|
|
420
|
+
|
|
421
|
+
# check for help
|
|
422
|
+
if set(argv) & set(self.help_keys):
|
|
423
|
+
sys.stderr.write(self.help() + "\n")
|
|
424
|
+
sys.exit(0)
|
|
425
|
+
|
|
426
|
+
try:
|
|
427
|
+
# handle options and flags
|
|
428
|
+
# note that parsed_args and positional_args are mutated throughout this chain
|
|
429
|
+
parsed_args, positional_args = self.consume_argv(argv)
|
|
430
|
+
|
|
431
|
+
self.assign_positional_args(parsed_args, positional_args)
|
|
432
|
+
self.raise_if_extra_positional_args(positional_args)
|
|
433
|
+
self.apply_defaults(parsed_args)
|
|
434
|
+
self.run_validators(parsed_args)
|
|
435
|
+
except ArgumentError:
|
|
436
|
+
raise
|
|
437
|
+
|
|
438
|
+
return parsed_args
|
|
File without changes
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "argspec"
|
|
7
|
+
version = "0.3.0"
|
|
8
|
+
description = "A library for cleanly and succinctly performing type-safe command-line argument parsing via a declarative interface."
|
|
9
|
+
readme="README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"typewire>=0.1.0",
|
|
13
|
+
"typing-extensions>=4.15.0",
|
|
14
|
+
]
|
|
15
|
+
authors=[
|
|
16
|
+
{ name = "Lily Ellington", email = "lilell_@outlook.com" }
|
|
17
|
+
]
|
|
18
|
+
keywords = [
|
|
19
|
+
"command-line",
|
|
20
|
+
"cli",
|
|
21
|
+
"arguments",
|
|
22
|
+
"argument-parser",
|
|
23
|
+
"argument-parsing",
|
|
24
|
+
"argparse",
|
|
25
|
+
"type-safe"
|
|
26
|
+
]
|
|
27
|
+
classifiers = [
|
|
28
|
+
"Development Status :: 4 - Beta",
|
|
29
|
+
"Intended Audience :: Developers",
|
|
30
|
+
"License :: OSI Approved :: MIT License",
|
|
31
|
+
"Operating System :: OS Independent",
|
|
32
|
+
"Programming Language :: Python",
|
|
33
|
+
"Programming Language :: Python :: 3",
|
|
34
|
+
"Programming Language :: Python :: 3.10",
|
|
35
|
+
"Programming Language :: Python :: 3.11",
|
|
36
|
+
"Programming Language :: Python :: 3.12",
|
|
37
|
+
"Programming Language :: Python :: 3.13",
|
|
38
|
+
"Programming Language :: Python :: 3.14",
|
|
39
|
+
"Topic :: Software Development :: Libraries",
|
|
40
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
41
|
+
"Topic :: Utilities"
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[tool.ruff]
|
|
45
|
+
line-length = 120
|
|
46
|
+
target-version = "py310"
|
|
47
|
+
|
|
48
|
+
[tool.ruff.lint]
|
|
49
|
+
select = ["E", "F", "I"]
|
|
50
|
+
ignore = ["I001"]
|
|
51
|
+
|
|
52
|
+
[tool.ruff.isort]
|
|
53
|
+
known-local-folder = ["argspec"]
|
|
54
|
+
force-single-line = false
|
|
55
|
+
lines-after-imports = 1
|
|
56
|
+
force-sort-within-sections = true
|
|
57
|
+
order-by-type = false
|
|
58
|
+
|
|
59
|
+
[dependency-groups]
|
|
60
|
+
dev = [
|
|
61
|
+
"mypy>=1.19.1",
|
|
62
|
+
"pytest>=9.0.2",
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
[project.urls]
|
|
66
|
+
repository = "https://github.com/lilellia/argspec"
|
|
67
|
+
"Bug Tracker" = "https://github.com/lilellia/argspec/issues"
|
|
68
|
+
|
|
69
|
+
[tool.hatch.build]
|
|
70
|
+
include = [
|
|
71
|
+
"argspec"
|
|
72
|
+
]
|