argly 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.
- argly-0.1.0/LICENSE +21 -0
- argly-0.1.0/MANIFEST.in +4 -0
- argly-0.1.0/PKG-INFO +158 -0
- argly-0.1.0/README.md +125 -0
- argly-0.1.0/argly/__init__.py +17 -0
- argly-0.1.0/argly/__main__.py +3 -0
- argly-0.1.0/argly/_parser.py +271 -0
- argly-0.1.0/argly/app.py +151 -0
- argly-0.1.0/argly/cli.py +16 -0
- argly-0.1.0/argly/compiler.py +275 -0
- argly-0.1.0/argly/declarations.py +81 -0
- argly-0.1.0/argly/helpgen.py +187 -0
- argly-0.1.0/argly/py.typed +0 -0
- argly-0.1.0/argly/schema.py +166 -0
- argly-0.1.0/argly.egg-info/PKG-INFO +158 -0
- argly-0.1.0/argly.egg-info/SOURCES.txt +32 -0
- argly-0.1.0/argly.egg-info/dependency_links.txt +1 -0
- argly-0.1.0/argly.egg-info/entry_points.txt +2 -0
- argly-0.1.0/argly.egg-info/requires.txt +13 -0
- argly-0.1.0/argly.egg-info/top_level.txt +1 -0
- argly-0.1.0/benchmarks/bench.py +208 -0
- argly-0.1.0/examples/__init__.py +0 -0
- argly-0.1.0/examples/remote_cli/__init__.py +0 -0
- argly-0.1.0/examples/remote_cli/__main__.py +4 -0
- argly-0.1.0/examples/remote_cli/commands/__init__.py +0 -0
- argly-0.1.0/examples/remote_cli/commands/remote.py +33 -0
- argly-0.1.0/examples/remote_cli/commands/root.py +7 -0
- argly-0.1.0/examples/remote_cli/generated.py +93 -0
- argly-0.1.0/pyproject.toml +75 -0
- argly-0.1.0/setup.cfg +4 -0
- argly-0.1.0/setup.py +18 -0
- argly-0.1.0/tests/test_compiler.py +207 -0
- argly-0.1.0/tests/test_helpgen.py +177 -0
- argly-0.1.0/tests/test_parser.py +320 -0
argly-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Caprine Logic
|
|
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.
|
argly-0.1.0/MANIFEST.in
ADDED
argly-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: argly
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A fast, module-oriented command-line framework with lazy handlers.
|
|
5
|
+
Author: depthbomb
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Documentation, https://github.com/depthbomb/argly#readme
|
|
8
|
+
Project-URL: Source, https://github.com/depthbomb/argly
|
|
9
|
+
Project-URL: Issues, https://github.com/depthbomb/argly/issues
|
|
10
|
+
Project-URL: Releases, https://github.com/depthbomb/argly/releases
|
|
11
|
+
Keywords: cli,command-line,parser,arguments,lazy-loading
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.14
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: build>=1.2; extra == "dev"
|
|
23
|
+
Requires-Dist: mypy>=1.15; extra == "dev"
|
|
24
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
25
|
+
Requires-Dist: pytest-cov>=6; extra == "dev"
|
|
26
|
+
Requires-Dist: ruff>=0.11; extra == "dev"
|
|
27
|
+
Requires-Dist: twine>=6; extra == "dev"
|
|
28
|
+
Provides-Extra: cython
|
|
29
|
+
Requires-Dist: Cython>=3.1; extra == "cython"
|
|
30
|
+
Requires-Dist: setuptools>=77; extra == "cython"
|
|
31
|
+
Requires-Dist: wheel>=0.45; extra == "cython"
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
# argly
|
|
35
|
+
|
|
36
|
+
Requires Python 3.14 or newer.
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
python -m pip install argly
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
From a checkout, with your virtual environment activated:
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
python -m pip install -e ".[dev]"
|
|
46
|
+
python -m examples.remote_cli --help
|
|
47
|
+
python -m examples.remote_cli -v remote add origin --url=https://example.com -fvv
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The example just prints the parsed values. Its [remote commands](examples/remote_cli/commands/remote.py) live together in one file, with [global options](examples/remote_cli/commands/root.py) in another.
|
|
51
|
+
|
|
52
|
+
## Commands are functions
|
|
53
|
+
|
|
54
|
+
Here's what a command module could look like. Put this in `mycli/commands/remote.py`, with an `__init__.py` in each package directory:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from typing import Annotated
|
|
58
|
+
from argly import Flag, Count, Option, Argument, Inherited, group, command
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@group('remote', summary='Manage remotes.')
|
|
62
|
+
def remote(*, verbose: Annotated[int, Count('-v')] = 0) -> None:
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@command('remote add', summary='Add a remote.')
|
|
67
|
+
def add(
|
|
68
|
+
name: Annotated[str, Argument()],
|
|
69
|
+
*,
|
|
70
|
+
url: Annotated[str, Option('-u')],
|
|
71
|
+
verbose: Annotated[int, Inherited()],
|
|
72
|
+
force: Annotated[bool, Flag('-f')] = False,
|
|
73
|
+
) -> int:
|
|
74
|
+
print(f'Adding {name}: {url}, force={force}, verbosity={verbose}')
|
|
75
|
+
|
|
76
|
+
return 0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@command('remote list', summary='List remotes.')
|
|
80
|
+
def list_remotes(*, verbose: Annotated[int, Inherited()]) -> int:
|
|
81
|
+
print(f'Listing remotes at verbosity {verbose}')
|
|
82
|
+
|
|
83
|
+
return 0
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Parameter names become long options, so `url` gives you `--url`, and `-u` is its alias. Value options and positional arguments without defaults are required. Commands return an integer exit code, and the decorators leave them callable as ordinary Python functions.
|
|
87
|
+
|
|
88
|
+
A group declares options for its descendants; its function doesn't run. `Inherited()` passes an ancestor's option into a handler without repeating its definition or default. In this example, `remote -vv add ...` and `remote add ... -vv` both work.
|
|
89
|
+
|
|
90
|
+
Use `@group("")` for global options that can appear anywhere before `--`. Other group options become available after entering that group. Command paths define the nesting, so related handlers can share a file without having to mirror the hierarchy in folders.
|
|
91
|
+
|
|
92
|
+
## Familiar option syntax
|
|
93
|
+
|
|
94
|
+
Long names, short aliases, combined flags, and counters all work:
|
|
95
|
+
|
|
96
|
+
```text
|
|
97
|
+
--url example.com --url=example.com
|
|
98
|
+
-u example.com -uexample.com -u=example.com
|
|
99
|
+
-abc -vvv
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
In a short-option cluster, an option that takes a value consumes the rest of the token or the next argument. Use `--` when the remaining arguments should be treated literally.
|
|
103
|
+
|
|
104
|
+
Windows-style aliases are opt-in: declare something like `Option("-u", "/URL", "/U")` and enable `windows_options=True`, or use the generator's `--windows-options` switch. Matching is exact and case-sensitive, so an unregistered path like `/tmp` stays a value.
|
|
105
|
+
|
|
106
|
+
## Generate help and load commands lazily
|
|
107
|
+
|
|
108
|
+
Generate a small Python module containing the command registry and preformatted help:
|
|
109
|
+
|
|
110
|
+
```sh
|
|
111
|
+
argly gen --package mycli.commands --name mycli --output mycli/generated.py
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Then use it in `mycli/__main__.py`:
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
from argly import App
|
|
118
|
+
from mycli.generated import REGISTRY, get_help
|
|
119
|
+
|
|
120
|
+
raise SystemExit(App.from_registry(REGISTRY, help_lookup=get_help).run())
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Now you can run `python -m mycli remote add origin -u example.com`.
|
|
124
|
+
|
|
125
|
+
Generation imports your command modules to read their definitions. At runtime, only the selected command's module gets imported, and only its handler runs. Related commands in the same file share that import. Help comes straight from the generated text, without loading command modules.
|
|
126
|
+
|
|
127
|
+
Regenerate after changing command definitions. Add `--check` to the same generation command in CI to catch stale output without rewriting it. `python -m argly gen` works too.
|
|
128
|
+
|
|
129
|
+
While experimenting, you can skip generation and use `App.discover('mycli', 'mycli.commands').run()`. That imports the command modules up front.
|
|
130
|
+
|
|
131
|
+
## Performance and development
|
|
132
|
+
|
|
133
|
+
Argly builds its parser tables once and reuses them. The [benchmark script](benchmarks/bench.py) compares equivalent invocations with `argparse` and measures help lookup and startup separately.
|
|
134
|
+
|
|
135
|
+
With the development dependencies installed:
|
|
136
|
+
|
|
137
|
+
```sh
|
|
138
|
+
python -m pytest --cov=argly --cov-branch
|
|
139
|
+
python -m ruff check .
|
|
140
|
+
python -m ruff format --check .
|
|
141
|
+
python -m mypy
|
|
142
|
+
python -m build --outdir dist/release
|
|
143
|
+
python -m twine check --strict dist/release/*
|
|
144
|
+
python benchmarks/bench.py
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
The release artifacts go in `dist/release` to keep them separate from local native builds.
|
|
148
|
+
|
|
149
|
+
There's also an optional Cython build of the same parser. To build a native wheel in PowerShell, with a C compiler installed:
|
|
150
|
+
|
|
151
|
+
```powershell
|
|
152
|
+
python -m pip install -e ".[dev,cython]"
|
|
153
|
+
$env:ARGLY_CYTHON = "1"
|
|
154
|
+
python -m build --wheel --no-isolation
|
|
155
|
+
Remove-Item Env:\ARGLY_CYTHON
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
The native wheel is written to `dist`; install it to use the compiled parser. Regular builds use pure Python.
|
argly-0.1.0/README.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# argly
|
|
2
|
+
|
|
3
|
+
Requires Python 3.14 or newer.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
python -m pip install argly
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
From a checkout, with your virtual environment activated:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
python -m pip install -e ".[dev]"
|
|
13
|
+
python -m examples.remote_cli --help
|
|
14
|
+
python -m examples.remote_cli -v remote add origin --url=https://example.com -fvv
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The example just prints the parsed values. Its [remote commands](examples/remote_cli/commands/remote.py) live together in one file, with [global options](examples/remote_cli/commands/root.py) in another.
|
|
18
|
+
|
|
19
|
+
## Commands are functions
|
|
20
|
+
|
|
21
|
+
Here's what a command module could look like. Put this in `mycli/commands/remote.py`, with an `__init__.py` in each package directory:
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from typing import Annotated
|
|
25
|
+
from argly import Flag, Count, Option, Argument, Inherited, group, command
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@group('remote', summary='Manage remotes.')
|
|
29
|
+
def remote(*, verbose: Annotated[int, Count('-v')] = 0) -> None:
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@command('remote add', summary='Add a remote.')
|
|
34
|
+
def add(
|
|
35
|
+
name: Annotated[str, Argument()],
|
|
36
|
+
*,
|
|
37
|
+
url: Annotated[str, Option('-u')],
|
|
38
|
+
verbose: Annotated[int, Inherited()],
|
|
39
|
+
force: Annotated[bool, Flag('-f')] = False,
|
|
40
|
+
) -> int:
|
|
41
|
+
print(f'Adding {name}: {url}, force={force}, verbosity={verbose}')
|
|
42
|
+
|
|
43
|
+
return 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@command('remote list', summary='List remotes.')
|
|
47
|
+
def list_remotes(*, verbose: Annotated[int, Inherited()]) -> int:
|
|
48
|
+
print(f'Listing remotes at verbosity {verbose}')
|
|
49
|
+
|
|
50
|
+
return 0
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Parameter names become long options, so `url` gives you `--url`, and `-u` is its alias. Value options and positional arguments without defaults are required. Commands return an integer exit code, and the decorators leave them callable as ordinary Python functions.
|
|
54
|
+
|
|
55
|
+
A group declares options for its descendants; its function doesn't run. `Inherited()` passes an ancestor's option into a handler without repeating its definition or default. In this example, `remote -vv add ...` and `remote add ... -vv` both work.
|
|
56
|
+
|
|
57
|
+
Use `@group("")` for global options that can appear anywhere before `--`. Other group options become available after entering that group. Command paths define the nesting, so related handlers can share a file without having to mirror the hierarchy in folders.
|
|
58
|
+
|
|
59
|
+
## Familiar option syntax
|
|
60
|
+
|
|
61
|
+
Long names, short aliases, combined flags, and counters all work:
|
|
62
|
+
|
|
63
|
+
```text
|
|
64
|
+
--url example.com --url=example.com
|
|
65
|
+
-u example.com -uexample.com -u=example.com
|
|
66
|
+
-abc -vvv
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
In a short-option cluster, an option that takes a value consumes the rest of the token or the next argument. Use `--` when the remaining arguments should be treated literally.
|
|
70
|
+
|
|
71
|
+
Windows-style aliases are opt-in: declare something like `Option("-u", "/URL", "/U")` and enable `windows_options=True`, or use the generator's `--windows-options` switch. Matching is exact and case-sensitive, so an unregistered path like `/tmp` stays a value.
|
|
72
|
+
|
|
73
|
+
## Generate help and load commands lazily
|
|
74
|
+
|
|
75
|
+
Generate a small Python module containing the command registry and preformatted help:
|
|
76
|
+
|
|
77
|
+
```sh
|
|
78
|
+
argly gen --package mycli.commands --name mycli --output mycli/generated.py
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Then use it in `mycli/__main__.py`:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from argly import App
|
|
85
|
+
from mycli.generated import REGISTRY, get_help
|
|
86
|
+
|
|
87
|
+
raise SystemExit(App.from_registry(REGISTRY, help_lookup=get_help).run())
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Now you can run `python -m mycli remote add origin -u example.com`.
|
|
91
|
+
|
|
92
|
+
Generation imports your command modules to read their definitions. At runtime, only the selected command's module gets imported, and only its handler runs. Related commands in the same file share that import. Help comes straight from the generated text, without loading command modules.
|
|
93
|
+
|
|
94
|
+
Regenerate after changing command definitions. Add `--check` to the same generation command in CI to catch stale output without rewriting it. `python -m argly gen` works too.
|
|
95
|
+
|
|
96
|
+
While experimenting, you can skip generation and use `App.discover('mycli', 'mycli.commands').run()`. That imports the command modules up front.
|
|
97
|
+
|
|
98
|
+
## Performance and development
|
|
99
|
+
|
|
100
|
+
Argly builds its parser tables once and reuses them. The [benchmark script](benchmarks/bench.py) compares equivalent invocations with `argparse` and measures help lookup and startup separately.
|
|
101
|
+
|
|
102
|
+
With the development dependencies installed:
|
|
103
|
+
|
|
104
|
+
```sh
|
|
105
|
+
python -m pytest --cov=argly --cov-branch
|
|
106
|
+
python -m ruff check .
|
|
107
|
+
python -m ruff format --check .
|
|
108
|
+
python -m mypy
|
|
109
|
+
python -m build --outdir dist/release
|
|
110
|
+
python -m twine check --strict dist/release/*
|
|
111
|
+
python benchmarks/bench.py
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The release artifacts go in `dist/release` to keep them separate from local native builds.
|
|
115
|
+
|
|
116
|
+
There's also an optional Cython build of the same parser. To build a native wheel in PowerShell, with a C compiler installed:
|
|
117
|
+
|
|
118
|
+
```powershell
|
|
119
|
+
python -m pip install -e ".[dev,cython]"
|
|
120
|
+
$env:ARGLY_CYTHON = "1"
|
|
121
|
+
python -m build --wheel --no-isolation
|
|
122
|
+
Remove-Item Env:\ARGLY_CYTHON
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The native wheel is written to `dist`; install it to use the compiled parser. Regular builds use pure Python.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from argly._parser import UsageError
|
|
3
|
+
from argly.app import App, Invocation
|
|
4
|
+
from argly.declarations import Count, Flag, Option, Argument, Inherited, command, group
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
'App',
|
|
8
|
+
'Argument',
|
|
9
|
+
'Count',
|
|
10
|
+
'Flag',
|
|
11
|
+
'Inherited',
|
|
12
|
+
'Invocation',
|
|
13
|
+
'Option',
|
|
14
|
+
'UsageError',
|
|
15
|
+
'command',
|
|
16
|
+
'group',
|
|
17
|
+
]
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Any, Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class UsageError(ValueError):
|
|
6
|
+
"""An invalid invocation, reported by App.run with exit status 2."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ParseResult:
|
|
10
|
+
__slots__ = ('node', 'values', 'help_requested')
|
|
11
|
+
|
|
12
|
+
def __init__(self, node: Node, values: dict[str, Any], help_requested: bool) -> None:
|
|
13
|
+
self.node = node
|
|
14
|
+
self.values = values
|
|
15
|
+
self.help_requested = help_requested
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Node:
|
|
19
|
+
__slots__ = (
|
|
20
|
+
'path',
|
|
21
|
+
'summary',
|
|
22
|
+
'handler',
|
|
23
|
+
'bindings',
|
|
24
|
+
'options',
|
|
25
|
+
'arguments',
|
|
26
|
+
'children',
|
|
27
|
+
'lookup',
|
|
28
|
+
'defaults',
|
|
29
|
+
'required',
|
|
30
|
+
'mutable_defaults',
|
|
31
|
+
'windows_options',
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def __init__(self, data: dict[str, Any], parent: Optional[Node], windows: bool) -> None:
|
|
35
|
+
self.path: str = data['path']
|
|
36
|
+
self.summary: str = data['summary']
|
|
37
|
+
self.handler: Optional[str] = data['handler']
|
|
38
|
+
self.bindings: dict[str, str] = data['bindings']
|
|
39
|
+
self.arguments: list[dict[str, Any]] = [spec.copy() for spec in data['arguments']]
|
|
40
|
+
self.children: dict[str, Node] = {}
|
|
41
|
+
self.windows_options = windows
|
|
42
|
+
self.options: list[dict[str, Any]] = ([] if parent is None else parent.options) + data[
|
|
43
|
+
'options'
|
|
44
|
+
]
|
|
45
|
+
self.lookup: dict[str, dict[str, Any]] = {}
|
|
46
|
+
self.defaults: dict[str, Any] = {}
|
|
47
|
+
self.required: list[str] = []
|
|
48
|
+
for option in self.options:
|
|
49
|
+
for spelling in option['names']:
|
|
50
|
+
if windows or not spelling.startswith('/'):
|
|
51
|
+
self.lookup[spelling] = option
|
|
52
|
+
|
|
53
|
+
self.defaults[option['dest']] = _default_value(option)
|
|
54
|
+
if option['required']:
|
|
55
|
+
self.required.append(option['dest'])
|
|
56
|
+
|
|
57
|
+
for argument in self.arguments:
|
|
58
|
+
argument['default'] = _default_value(argument)
|
|
59
|
+
|
|
60
|
+
self.mutable_defaults = tuple(
|
|
61
|
+
dest for dest, default in self.defaults.items() if isinstance(default, list)
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _default_value(spec: dict[str, Any]) -> Any:
|
|
66
|
+
default = spec['default']
|
|
67
|
+
if default is not None and spec['type'] == 'path':
|
|
68
|
+
if spec['multiple']:
|
|
69
|
+
return [_convert(item, spec) for item in default]
|
|
70
|
+
|
|
71
|
+
return _convert(default, spec)
|
|
72
|
+
|
|
73
|
+
return default
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _convert(value: str, spec: dict[str, Any]) -> Any:
|
|
77
|
+
kind = spec['type']
|
|
78
|
+
try:
|
|
79
|
+
if kind == 'str':
|
|
80
|
+
result: Any = value
|
|
81
|
+
elif kind == 'int':
|
|
82
|
+
result = int(value)
|
|
83
|
+
elif kind == 'float':
|
|
84
|
+
result = float(value)
|
|
85
|
+
elif kind == 'path':
|
|
86
|
+
from pathlib import Path
|
|
87
|
+
|
|
88
|
+
result = Path(value)
|
|
89
|
+
else:
|
|
90
|
+
raise ValueError(f'unsupported value type: {kind}')
|
|
91
|
+
except (ValueError, OverflowError) as error:
|
|
92
|
+
raise UsageError(f'{spec["dest"]}: invalid {kind} value {value!r}') from error
|
|
93
|
+
|
|
94
|
+
choices = spec['choices']
|
|
95
|
+
choice_value = str(result) if kind == 'path' else result
|
|
96
|
+
if choices is not None and choice_value not in choices:
|
|
97
|
+
raise UsageError(f'{spec["dest"]}: choose from {", ".join(map(str, choices))}')
|
|
98
|
+
|
|
99
|
+
return result
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _store(values: dict[str, Any], spec: dict[str, Any], value: Optional[str]) -> None:
|
|
103
|
+
dest = spec['dest']
|
|
104
|
+
action = spec['action']
|
|
105
|
+
if action == 'flag':
|
|
106
|
+
values[dest] = not spec['default']
|
|
107
|
+
elif action == 'count':
|
|
108
|
+
values[dest] = values.get(dest, spec['default']) + 1
|
|
109
|
+
else:
|
|
110
|
+
assert value is not None
|
|
111
|
+
converted = _convert(value, spec)
|
|
112
|
+
if spec['multiple']:
|
|
113
|
+
if dest in values:
|
|
114
|
+
values[dest].append(converted)
|
|
115
|
+
else:
|
|
116
|
+
values[dest] = [converted]
|
|
117
|
+
else:
|
|
118
|
+
values[dest] = converted
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _looks_like_option(value: str, node: Node) -> bool:
|
|
122
|
+
if value == '--' or value in ('--help', '-h'):
|
|
123
|
+
return True
|
|
124
|
+
|
|
125
|
+
if value.startswith('-') and value != '-':
|
|
126
|
+
try:
|
|
127
|
+
float(value)
|
|
128
|
+
except ValueError:
|
|
129
|
+
return True
|
|
130
|
+
|
|
131
|
+
return node.windows_options and value.partition('=')[0] in node.lookup
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def parse(root: Node, argv: list[str]) -> ParseResult:
|
|
135
|
+
node = root
|
|
136
|
+
values: dict[str, Any] = {}
|
|
137
|
+
positionals: list[str] = []
|
|
138
|
+
help_requested = False
|
|
139
|
+
literal = False
|
|
140
|
+
index = 0
|
|
141
|
+
size = len(argv)
|
|
142
|
+
while index < size:
|
|
143
|
+
token = argv[index]
|
|
144
|
+
index += 1
|
|
145
|
+
if literal:
|
|
146
|
+
positionals.append(token)
|
|
147
|
+
continue
|
|
148
|
+
|
|
149
|
+
if token == '--':
|
|
150
|
+
literal = True
|
|
151
|
+
continue
|
|
152
|
+
|
|
153
|
+
if token in ('--help', '-h'):
|
|
154
|
+
help_requested = True
|
|
155
|
+
continue
|
|
156
|
+
|
|
157
|
+
name, equal, attached = token.partition('=')
|
|
158
|
+
spec = node.lookup.get(name)
|
|
159
|
+
if spec is not None:
|
|
160
|
+
if spec['action'] != 'value':
|
|
161
|
+
if equal:
|
|
162
|
+
raise UsageError(f'{name} does not take a value')
|
|
163
|
+
|
|
164
|
+
_store(values, spec, None)
|
|
165
|
+
continue
|
|
166
|
+
|
|
167
|
+
if not equal:
|
|
168
|
+
if index == size or _looks_like_option(argv[index], node):
|
|
169
|
+
raise UsageError(
|
|
170
|
+
f"{name} requires a value (use {name}=VALUE for a value starting with '-')"
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
attached = argv[index]
|
|
174
|
+
index += 1
|
|
175
|
+
|
|
176
|
+
_store(values, spec, attached)
|
|
177
|
+
continue
|
|
178
|
+
|
|
179
|
+
if token.startswith('--'):
|
|
180
|
+
raise UsageError(f'unknown option {name!r}')
|
|
181
|
+
|
|
182
|
+
negative_number = False
|
|
183
|
+
if (
|
|
184
|
+
token.startswith('-')
|
|
185
|
+
and len(token) > 1
|
|
186
|
+
and (token[1].isdigit() or token[1] == '.')
|
|
187
|
+
and node.arguments
|
|
188
|
+
):
|
|
189
|
+
try:
|
|
190
|
+
float(token)
|
|
191
|
+
negative_number = True
|
|
192
|
+
except ValueError:
|
|
193
|
+
pass
|
|
194
|
+
|
|
195
|
+
if token.startswith('-') and token != '-' and not negative_number:
|
|
196
|
+
offset = 1
|
|
197
|
+
while offset < len(token):
|
|
198
|
+
short = '-' + token[offset]
|
|
199
|
+
if short == '-h':
|
|
200
|
+
help_requested = True
|
|
201
|
+
offset += 1
|
|
202
|
+
continue
|
|
203
|
+
|
|
204
|
+
spec = node.lookup.get(short)
|
|
205
|
+
if spec is None:
|
|
206
|
+
raise UsageError(f'unknown option {short!r}')
|
|
207
|
+
|
|
208
|
+
offset += 1
|
|
209
|
+
if spec['action'] != 'value':
|
|
210
|
+
_store(values, spec, None)
|
|
211
|
+
continue
|
|
212
|
+
|
|
213
|
+
attached = token[offset:]
|
|
214
|
+
if attached.startswith('='):
|
|
215
|
+
attached = attached[1:]
|
|
216
|
+
elif not attached:
|
|
217
|
+
if index == size or _looks_like_option(argv[index], node):
|
|
218
|
+
raise UsageError(f'{short} requires a value')
|
|
219
|
+
|
|
220
|
+
attached = argv[index]
|
|
221
|
+
index += 1
|
|
222
|
+
|
|
223
|
+
_store(values, spec, attached)
|
|
224
|
+
break
|
|
225
|
+
|
|
226
|
+
continue
|
|
227
|
+
|
|
228
|
+
if node.children and not positionals:
|
|
229
|
+
child = node.children.get(token)
|
|
230
|
+
if child is None:
|
|
231
|
+
raise UsageError(f'unknown command {token!r} under {node.path or "<root>"}')
|
|
232
|
+
|
|
233
|
+
node = child
|
|
234
|
+
continue
|
|
235
|
+
|
|
236
|
+
positionals.append(token)
|
|
237
|
+
|
|
238
|
+
if help_requested:
|
|
239
|
+
return ParseResult(node, values, True)
|
|
240
|
+
|
|
241
|
+
for dest in node.required:
|
|
242
|
+
if dest not in values:
|
|
243
|
+
raise UsageError(f'missing required option --{dest.replace("_", "-")}')
|
|
244
|
+
|
|
245
|
+
result = node.defaults.copy()
|
|
246
|
+
for dest in node.mutable_defaults:
|
|
247
|
+
result[dest] = result[dest].copy()
|
|
248
|
+
|
|
249
|
+
result.update(values)
|
|
250
|
+
offset = 0
|
|
251
|
+
for spec in node.arguments:
|
|
252
|
+
dest = spec['dest']
|
|
253
|
+
if spec['multiple']:
|
|
254
|
+
rest = positionals[offset:]
|
|
255
|
+
if spec['required'] and not rest:
|
|
256
|
+
raise UsageError(f'missing required argument {dest}')
|
|
257
|
+
|
|
258
|
+
result[dest] = [_convert(value, spec) for value in rest] if rest else spec['default'][:]
|
|
259
|
+
offset = len(positionals)
|
|
260
|
+
elif offset < len(positionals):
|
|
261
|
+
result[dest] = _convert(positionals[offset], spec)
|
|
262
|
+
offset += 1
|
|
263
|
+
elif spec['required']:
|
|
264
|
+
raise UsageError(f'missing required argument {dest}')
|
|
265
|
+
else:
|
|
266
|
+
result[dest] = spec['default']
|
|
267
|
+
|
|
268
|
+
if offset < len(positionals):
|
|
269
|
+
raise UsageError(f'unexpected argument {positionals[offset]!r}')
|
|
270
|
+
|
|
271
|
+
return ParseResult(node, result, False)
|