usage-spec-argparse 1.0.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.
- usage_spec_argparse-1.0.0/.gitignore +8 -0
- usage_spec_argparse-1.0.0/PKG-INFO +87 -0
- usage_spec_argparse-1.0.0/README.md +67 -0
- usage_spec_argparse-1.0.0/pyproject.toml +35 -0
- usage_spec_argparse-1.0.0/src/argparse_usage/__init__.py +39 -0
- usage_spec_argparse-1.0.0/src/argparse_usage/convert.py +226 -0
- usage_spec_argparse-1.0.0/tests/test_argparse.py +593 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: usage-spec-argparse
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Generates usage spec for CLIs written with argparse
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Keywords: argparse,cli,kdl,shell-completion,usage
|
|
7
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
14
|
+
Classifier: Topic :: Utilities
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Requires-Dist: usage-spec
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# usage-spec-argparse
|
|
22
|
+
|
|
23
|
+
Generates [usage spec](https://usage.jdx.dev) for CLIs written with [argparse](https://docs.python.org/3/library/argparse.html).
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
pip install usage-spec-argparse
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
import argparse
|
|
35
|
+
from argparse_usage import generate
|
|
36
|
+
|
|
37
|
+
parser = argparse.ArgumentParser(prog="mycli", description="My CLI tool")
|
|
38
|
+
parser.add_argument("-v", "--verbose", action="store_true", help="Enable verbose output")
|
|
39
|
+
parser.add_argument("-f", "--file", help="Input file")
|
|
40
|
+
parser.add_argument("--no-color", action="store_false", help="Disable colored output")
|
|
41
|
+
|
|
42
|
+
print(generate(parser))
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## API
|
|
46
|
+
|
|
47
|
+
### `generate(parser, bin_name=None)`
|
|
48
|
+
|
|
49
|
+
Generates a usage spec in KDL format from an `argparse.ArgumentParser`.
|
|
50
|
+
|
|
51
|
+
### `generate_kdl(parser, bin_name=None)`
|
|
52
|
+
|
|
53
|
+
Alias for `generate()`.
|
|
54
|
+
|
|
55
|
+
### `generate_json(parser, bin_name=None)`
|
|
56
|
+
|
|
57
|
+
Generates a usage spec in JSON format.
|
|
58
|
+
|
|
59
|
+
### `convert_root(parser, bin_name=None)`
|
|
60
|
+
|
|
61
|
+
Converts an `argparse.ArgumentParser` to the `Spec` data structure.
|
|
62
|
+
|
|
63
|
+
## Supported Features
|
|
64
|
+
|
|
65
|
+
| argparse Feature | Usage Spec Mapping |
|
|
66
|
+
|---|---|
|
|
67
|
+
| `prog` | `name` / `bin` |
|
|
68
|
+
| `--version` action | `version` |
|
|
69
|
+
| `description` | `about` |
|
|
70
|
+
| `epilog` | `long_about` |
|
|
71
|
+
| `add_argument()` (optional) | `flag` |
|
|
72
|
+
| `add_argument()` (positional) | `arg` |
|
|
73
|
+
| `required=True` | `flag required=#true` |
|
|
74
|
+
| `action="store_true"/"store_false"` | Boolean flag (no arg) |
|
|
75
|
+
| `action="store_false"` | `negate` |
|
|
76
|
+
| `action="count"` | `count=#true` |
|
|
77
|
+
| `nargs="?"` | `required=#false` |
|
|
78
|
+
| `nargs="*"/"+"` | `var=#true` |
|
|
79
|
+
| `choices=[...]` | `choices` |
|
|
80
|
+
| `default=...` | `default` |
|
|
81
|
+
| `help=SUPPRESS` | `hide=#true` |
|
|
82
|
+
| `add_subparsers()` | `cmd` (recursive) |
|
|
83
|
+
| Non-runnable subcommand groups | `subcommand_required=#true` |
|
|
84
|
+
|
|
85
|
+
## License
|
|
86
|
+
|
|
87
|
+
MIT
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# usage-spec-argparse
|
|
2
|
+
|
|
3
|
+
Generates [usage spec](https://usage.jdx.dev) for CLIs written with [argparse](https://docs.python.org/3/library/argparse.html).
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pip install usage-spec-argparse
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import argparse
|
|
15
|
+
from argparse_usage import generate
|
|
16
|
+
|
|
17
|
+
parser = argparse.ArgumentParser(prog="mycli", description="My CLI tool")
|
|
18
|
+
parser.add_argument("-v", "--verbose", action="store_true", help="Enable verbose output")
|
|
19
|
+
parser.add_argument("-f", "--file", help="Input file")
|
|
20
|
+
parser.add_argument("--no-color", action="store_false", help="Disable colored output")
|
|
21
|
+
|
|
22
|
+
print(generate(parser))
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## API
|
|
26
|
+
|
|
27
|
+
### `generate(parser, bin_name=None)`
|
|
28
|
+
|
|
29
|
+
Generates a usage spec in KDL format from an `argparse.ArgumentParser`.
|
|
30
|
+
|
|
31
|
+
### `generate_kdl(parser, bin_name=None)`
|
|
32
|
+
|
|
33
|
+
Alias for `generate()`.
|
|
34
|
+
|
|
35
|
+
### `generate_json(parser, bin_name=None)`
|
|
36
|
+
|
|
37
|
+
Generates a usage spec in JSON format.
|
|
38
|
+
|
|
39
|
+
### `convert_root(parser, bin_name=None)`
|
|
40
|
+
|
|
41
|
+
Converts an `argparse.ArgumentParser` to the `Spec` data structure.
|
|
42
|
+
|
|
43
|
+
## Supported Features
|
|
44
|
+
|
|
45
|
+
| argparse Feature | Usage Spec Mapping |
|
|
46
|
+
|---|---|
|
|
47
|
+
| `prog` | `name` / `bin` |
|
|
48
|
+
| `--version` action | `version` |
|
|
49
|
+
| `description` | `about` |
|
|
50
|
+
| `epilog` | `long_about` |
|
|
51
|
+
| `add_argument()` (optional) | `flag` |
|
|
52
|
+
| `add_argument()` (positional) | `arg` |
|
|
53
|
+
| `required=True` | `flag required=#true` |
|
|
54
|
+
| `action="store_true"/"store_false"` | Boolean flag (no arg) |
|
|
55
|
+
| `action="store_false"` | `negate` |
|
|
56
|
+
| `action="count"` | `count=#true` |
|
|
57
|
+
| `nargs="?"` | `required=#false` |
|
|
58
|
+
| `nargs="*"/"+"` | `var=#true` |
|
|
59
|
+
| `choices=[...]` | `choices` |
|
|
60
|
+
| `default=...` | `default` |
|
|
61
|
+
| `help=SUPPRESS` | `hide=#true` |
|
|
62
|
+
| `add_subparsers()` | `cmd` (recursive) |
|
|
63
|
+
| Non-runnable subcommand groups | `subcommand_required=#true` |
|
|
64
|
+
|
|
65
|
+
## License
|
|
66
|
+
|
|
67
|
+
MIT
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "usage-spec-argparse"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Generates usage spec for CLIs written with argparse"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
keywords = ["argparse", "usage", "cli", "kdl", "shell-completion"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"License :: OSI Approved :: MIT License",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.10",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
21
|
+
"Topic :: Utilities",
|
|
22
|
+
]
|
|
23
|
+
dependencies = ["usage-spec"]
|
|
24
|
+
|
|
25
|
+
[project.optional-dependencies]
|
|
26
|
+
dev = ["pytest>=8.0"]
|
|
27
|
+
|
|
28
|
+
[tool.hatch.build.targets.wheel]
|
|
29
|
+
packages = ["src/argparse_usage"]
|
|
30
|
+
|
|
31
|
+
[tool.hatch.build.targets.wheel.sources]
|
|
32
|
+
"src" = ""
|
|
33
|
+
|
|
34
|
+
[tool.pytest.ini_options]
|
|
35
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Usage spec integration for argparse."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from usage_spec import Spec, SpecArg, SpecFlag, SpecCommand, SpecChoices, render_kdl, validate_kdl, render_json, generate as core_generate
|
|
8
|
+
|
|
9
|
+
from .convert import convert_root
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"convert_root",
|
|
13
|
+
"generate",
|
|
14
|
+
"generate_kdl",
|
|
15
|
+
"generate_json",
|
|
16
|
+
"render_kdl",
|
|
17
|
+
"Spec",
|
|
18
|
+
"SpecArg",
|
|
19
|
+
"SpecFlag",
|
|
20
|
+
"SpecCommand",
|
|
21
|
+
"SpecChoices",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def generate(parser: argparse.ArgumentParser, bin_name: str | None = None) -> str:
|
|
26
|
+
"""Generate usage spec in KDL format from an argparse.ArgumentParser."""
|
|
27
|
+
spec = convert_root(parser, bin_name)
|
|
28
|
+
return core_generate(spec, format="kdl", comment="@generated by usage-spec-argparse from argparse metadata")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def generate_kdl(parser: argparse.ArgumentParser, bin_name: str | None = None) -> str:
|
|
32
|
+
"""Generate usage spec in KDL format (alias for generate)."""
|
|
33
|
+
return generate(parser, bin_name)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def generate_json(parser: argparse.ArgumentParser, bin_name: str | None = None) -> str:
|
|
37
|
+
"""Generate usage spec in JSON format from an argparse.ArgumentParser."""
|
|
38
|
+
spec = convert_root(parser, bin_name)
|
|
39
|
+
return core_generate(spec, format="json")
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Convert argparse.ArgumentParser to usage spec."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from usage_spec import Spec, SpecArg, SpecFlag, SpecCommand, SpecChoices
|
|
9
|
+
|
|
10
|
+
# Action class names that represent boolean flags
|
|
11
|
+
_BOOL_ACTION_NAMES = frozenset({
|
|
12
|
+
"_StoreTrueAction",
|
|
13
|
+
"_StoreFalseAction",
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
# Action class names that represent count flags
|
|
17
|
+
_COUNT_ACTION_NAMES = frozenset({
|
|
18
|
+
"_CountAction",
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
# Action class names for subparsers
|
|
22
|
+
_SUBPARSERS_ACTION_NAMES = frozenset({
|
|
23
|
+
"_SubParsersAction",
|
|
24
|
+
"SubParsersAction",
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
# Built-in flag names to skip
|
|
28
|
+
_BUILTIN_FLAG_NAMES = frozenset({"help", "version"})
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _is_bool_action(action: argparse.Action) -> bool:
|
|
32
|
+
return type(action).__name__ in _BOOL_ACTION_NAMES
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _is_count_action(action: argparse.Action) -> bool:
|
|
36
|
+
return type(action).__name__ in _COUNT_ACTION_NAMES
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _is_subparsers_action(action: argparse.Action) -> bool:
|
|
40
|
+
return type(action).__name__ in _SUBPARSERS_ACTION_NAMES
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _is_optional(action: argparse.Action) -> bool:
|
|
44
|
+
return bool(action.option_strings)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _extract_short_long(option_strings: list[str]) -> tuple[str, str]:
|
|
48
|
+
short = ""
|
|
49
|
+
long = ""
|
|
50
|
+
for opt in option_strings:
|
|
51
|
+
if opt.startswith("--"):
|
|
52
|
+
long = opt[2:]
|
|
53
|
+
elif opt.startswith("-"):
|
|
54
|
+
short = opt[1:]
|
|
55
|
+
return short, long
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _convert_arg(action: argparse.Action) -> SpecArg:
|
|
59
|
+
nargs = action.nargs
|
|
60
|
+
# Use argparse's own 'required' attribute for positional args
|
|
61
|
+
required = getattr(action, "required", True)
|
|
62
|
+
# Variadic: nargs is '*' or '+' or argparse.REMAINDER
|
|
63
|
+
variadic = nargs in ("*", "+", argparse.REMAINDER) if nargs else False
|
|
64
|
+
|
|
65
|
+
result = SpecArg(
|
|
66
|
+
name=action.dest or action.metavar or "",
|
|
67
|
+
help=action.help if action.help != argparse.SUPPRESS else "",
|
|
68
|
+
required=required,
|
|
69
|
+
var=variadic,
|
|
70
|
+
hide=action.help == argparse.SUPPRESS,
|
|
71
|
+
default=[str(action.default)] if action.default not in (None, argparse.SUPPRESS) else [],
|
|
72
|
+
choices=None,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
if action.choices:
|
|
76
|
+
result.choices = SpecChoices(values=[str(c) for c in action.choices])
|
|
77
|
+
|
|
78
|
+
return result
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _convert_flag(action: argparse.Action) -> SpecFlag:
|
|
82
|
+
short, long = _extract_short_long(action.option_strings)
|
|
83
|
+
is_bool = _is_bool_action(action)
|
|
84
|
+
is_count = _is_count_action(action)
|
|
85
|
+
is_store_false = type(action).__name__ == "_StoreFalseAction"
|
|
86
|
+
|
|
87
|
+
flag = SpecFlag(
|
|
88
|
+
short=short,
|
|
89
|
+
long=long,
|
|
90
|
+
help=action.help if action.help != argparse.SUPPRESS else "",
|
|
91
|
+
help_long="",
|
|
92
|
+
required=bool(action.required) if hasattr(action, "required") else False,
|
|
93
|
+
hide=action.help == argparse.SUPPRESS,
|
|
94
|
+
global_=False,
|
|
95
|
+
count=is_count,
|
|
96
|
+
var=bool(action.nargs and action.nargs in ("*", "+")),
|
|
97
|
+
negate=f"--{long}" if is_store_false else "",
|
|
98
|
+
deprecated="",
|
|
99
|
+
default=[],
|
|
100
|
+
default_bool=None,
|
|
101
|
+
env="",
|
|
102
|
+
arg=None,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# Non-boolean, non-count options have an argument
|
|
106
|
+
if not is_bool and not is_count:
|
|
107
|
+
arg_name = (long or short).replace("-", "_").upper()
|
|
108
|
+
flag.arg = SpecArg(
|
|
109
|
+
name=arg_name,
|
|
110
|
+
help="",
|
|
111
|
+
required=flag.required,
|
|
112
|
+
var=flag.var,
|
|
113
|
+
hide=False,
|
|
114
|
+
default=[],
|
|
115
|
+
choices=None,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
if action.choices:
|
|
119
|
+
flag.arg.choices = SpecChoices(values=[str(c) for c in action.choices])
|
|
120
|
+
|
|
121
|
+
# Default values
|
|
122
|
+
if action.default not in (None, argparse.SUPPRESS, True, False):
|
|
123
|
+
if is_bool or is_count:
|
|
124
|
+
pass # skip for booleans
|
|
125
|
+
else:
|
|
126
|
+
flag.default = [str(action.default)]
|
|
127
|
+
elif action.default is True:
|
|
128
|
+
flag.default_bool = True
|
|
129
|
+
# False defaults are omitted for booleans
|
|
130
|
+
|
|
131
|
+
return flag
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _get_subcommand_helps(parser: argparse.ArgumentParser) -> dict[str, str]:
|
|
135
|
+
"""Extract help texts from subparsers' _choices_actions."""
|
|
136
|
+
helps: dict[str, str] = {}
|
|
137
|
+
for action in parser._actions:
|
|
138
|
+
if _is_subparsers_action(action) and hasattr(action, "_choices_actions"):
|
|
139
|
+
for ca in action._choices_actions:
|
|
140
|
+
if ca.dest and ca.help:
|
|
141
|
+
helps[ca.dest] = ca.help
|
|
142
|
+
return helps
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _convert_command(parser: argparse.ArgumentParser, help_override: str = "") -> SpecCommand:
|
|
146
|
+
name = parser.prog
|
|
147
|
+
# Strip parent prefix from name (e.g. "app sub" -> "sub")
|
|
148
|
+
if " " in name:
|
|
149
|
+
name = name.rsplit(" ", 1)[-1]
|
|
150
|
+
|
|
151
|
+
# Use help_override from _choices_actions if available, otherwise description
|
|
152
|
+
cmd_help = help_override or parser.description or ""
|
|
153
|
+
|
|
154
|
+
sc = SpecCommand(
|
|
155
|
+
name=name,
|
|
156
|
+
help=cmd_help,
|
|
157
|
+
help_long="",
|
|
158
|
+
hide=False,
|
|
159
|
+
deprecated="",
|
|
160
|
+
aliases=[],
|
|
161
|
+
subcommand_required=False,
|
|
162
|
+
flags=[],
|
|
163
|
+
args=[],
|
|
164
|
+
cmds=[],
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
sub_helps = _get_subcommand_helps(parser)
|
|
168
|
+
|
|
169
|
+
for action in parser._actions:
|
|
170
|
+
if _is_optional(action):
|
|
171
|
+
action_name = _extract_short_long(action.option_strings)[1] or _extract_short_long(action.option_strings)[0]
|
|
172
|
+
if action_name in _BUILTIN_FLAG_NAMES:
|
|
173
|
+
continue
|
|
174
|
+
sc.flags.append(_convert_flag(action))
|
|
175
|
+
elif _is_subparsers_action(action):
|
|
176
|
+
if hasattr(action, "choices") and action.choices:
|
|
177
|
+
for sub_name, sub_parser in action.choices.items():
|
|
178
|
+
sc.cmds.append(_convert_command(sub_parser, sub_helps.get(sub_name, "")))
|
|
179
|
+
else:
|
|
180
|
+
sc.args.append(_convert_arg(action))
|
|
181
|
+
|
|
182
|
+
# subcommand_required: has subcommands but no positional args
|
|
183
|
+
if sc.cmds and not sc.args:
|
|
184
|
+
sc.subcommand_required = True
|
|
185
|
+
|
|
186
|
+
return sc
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def convert_root(parser: argparse.ArgumentParser, bin_name: str | None = None) -> Spec:
|
|
190
|
+
"""Convert an argparse.ArgumentParser to a Spec object."""
|
|
191
|
+
name = bin_name or parser.prog
|
|
192
|
+
|
|
193
|
+
spec = Spec(
|
|
194
|
+
name=name,
|
|
195
|
+
bin=name,
|
|
196
|
+
version="",
|
|
197
|
+
about=parser.description or "",
|
|
198
|
+
long=parser.epilog or "",
|
|
199
|
+
usage=parser.usage or "",
|
|
200
|
+
flags=[],
|
|
201
|
+
args=[],
|
|
202
|
+
cmds=[],
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
sub_helps = _get_subcommand_helps(parser)
|
|
206
|
+
|
|
207
|
+
for action in parser._actions:
|
|
208
|
+
if _is_optional(action):
|
|
209
|
+
action_name = _extract_short_long(action.option_strings)[1] or _extract_short_long(action.option_strings)[0]
|
|
210
|
+
# Extract version string before skipping
|
|
211
|
+
if action_name == "version" and hasattr(action, "version"):
|
|
212
|
+
spec.version = action.version or ""
|
|
213
|
+
continue
|
|
214
|
+
# Skip built-in flags
|
|
215
|
+
if action_name in _BUILTIN_FLAG_NAMES:
|
|
216
|
+
continue
|
|
217
|
+
spec.flags.append(_convert_flag(action))
|
|
218
|
+
elif _is_subparsers_action(action):
|
|
219
|
+
if hasattr(action, "choices") and action.choices:
|
|
220
|
+
for sub_name, sub_parser in action.choices.items():
|
|
221
|
+
spec.cmds.append(_convert_command(sub_parser, sub_helps.get(sub_name, "")))
|
|
222
|
+
else:
|
|
223
|
+
# Positional argument
|
|
224
|
+
spec.args.append(_convert_arg(action))
|
|
225
|
+
|
|
226
|
+
return spec
|
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
"""Tests for argparse usage spec integration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
|
|
8
|
+
from argparse_usage import generate, generate_kdl, generate_json, convert_root
|
|
9
|
+
from usage_spec import validate_kdl
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TestSimpleCommand:
|
|
13
|
+
def test_generates_spec_for_basic_cli(self):
|
|
14
|
+
p = argparse.ArgumentParser(prog="mycli", description="A simple CLI")
|
|
15
|
+
p.add_argument("-v", "--verbose", action="store_true", help="Enable verbose output")
|
|
16
|
+
p.add_argument("-c", "--config", help="Config file path")
|
|
17
|
+
|
|
18
|
+
output = generate(p)
|
|
19
|
+
|
|
20
|
+
assert "name mycli" in output
|
|
21
|
+
assert "bin mycli" in output
|
|
22
|
+
assert 'about "A simple CLI"' in output
|
|
23
|
+
assert 'flag "-v --verbose"' in output
|
|
24
|
+
assert 'help="Enable verbose output"' in output
|
|
25
|
+
assert 'flag "-c --config"' in output
|
|
26
|
+
assert 'help="Config file path"' in output
|
|
27
|
+
|
|
28
|
+
def test_includes_generated_comment_header(self):
|
|
29
|
+
p = argparse.ArgumentParser(prog="app")
|
|
30
|
+
output = generate(p)
|
|
31
|
+
assert "// @generated by usage-spec-argparse from argparse metadata" in output
|
|
32
|
+
|
|
33
|
+
def test_uses_custom_bin_name(self):
|
|
34
|
+
p = argparse.ArgumentParser(prog="app")
|
|
35
|
+
output = generate(p, "my-app")
|
|
36
|
+
assert "bin my-app" in output
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TestVersion:
|
|
40
|
+
def test_extracts_version_string(self):
|
|
41
|
+
p = argparse.ArgumentParser(prog="app")
|
|
42
|
+
p.add_argument("--version", action="version", version="2.0.0")
|
|
43
|
+
spec = convert_root(p)
|
|
44
|
+
assert spec.version == "2.0.0"
|
|
45
|
+
|
|
46
|
+
def test_skips_version_field_when_not_set(self):
|
|
47
|
+
p = argparse.ArgumentParser(prog="app")
|
|
48
|
+
output = generate(p)
|
|
49
|
+
assert "version " not in output
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class TestAboutDescription:
|
|
53
|
+
def test_extracts_description_as_about(self):
|
|
54
|
+
p = argparse.ArgumentParser(prog="app", description="A simple CLI tool")
|
|
55
|
+
spec = convert_root(p)
|
|
56
|
+
assert spec.about == "A simple CLI tool"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class TestNestedSubcommands:
|
|
60
|
+
def test_renders_subcommands_recursively(self):
|
|
61
|
+
p = argparse.ArgumentParser(prog="app", description="An app")
|
|
62
|
+
subparsers = p.add_subparsers()
|
|
63
|
+
sub_a = subparsers.add_parser("sub", help="A subcommand")
|
|
64
|
+
sub_a_sub = sub_a.add_subparsers()
|
|
65
|
+
sub_a_sub.add_parser("nested", help="A nested command")
|
|
66
|
+
|
|
67
|
+
output = generate(p)
|
|
68
|
+
|
|
69
|
+
assert "cmd sub" in output
|
|
70
|
+
assert 'help="A subcommand"' in output
|
|
71
|
+
assert "cmd nested" in output
|
|
72
|
+
assert 'help="A nested command"' in output
|
|
73
|
+
|
|
74
|
+
def test_renders_deeply_nested_command_structure(self):
|
|
75
|
+
p = argparse.ArgumentParser(prog="myapp", description="My application")
|
|
76
|
+
sub1 = p.add_subparsers()
|
|
77
|
+
db = sub1.add_parser("db", help="Database operations")
|
|
78
|
+
sub2 = db.add_subparsers()
|
|
79
|
+
migrate = sub2.add_parser("migrate", help="Run migrations")
|
|
80
|
+
sub3 = migrate.add_subparsers()
|
|
81
|
+
sub3.add_parser("up", help="Apply migrations")
|
|
82
|
+
sub3.add_parser("down", help="Rollback migrations")
|
|
83
|
+
|
|
84
|
+
output = generate(p)
|
|
85
|
+
|
|
86
|
+
assert "cmd db" in output
|
|
87
|
+
assert "cmd migrate" in output
|
|
88
|
+
assert "cmd up" in output
|
|
89
|
+
assert "cmd down" in output
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class TestOptions:
|
|
93
|
+
def test_renders_short_and_long_flags(self):
|
|
94
|
+
p = argparse.ArgumentParser(prog="app")
|
|
95
|
+
p.add_argument("-v", "--verbose", action="store_true", help="Be verbose")
|
|
96
|
+
p.add_argument("-o", "--output", help="Output dir")
|
|
97
|
+
|
|
98
|
+
output = generate(p)
|
|
99
|
+
|
|
100
|
+
assert 'flag "-v --verbose"' in output
|
|
101
|
+
assert 'flag "-o --output"' in output
|
|
102
|
+
|
|
103
|
+
def test_marks_required_options(self):
|
|
104
|
+
p = argparse.ArgumentParser(prog="app")
|
|
105
|
+
p.add_argument("--env", required=True, help="Target environment")
|
|
106
|
+
|
|
107
|
+
output = generate(p)
|
|
108
|
+
|
|
109
|
+
assert "required=#true" in output
|
|
110
|
+
|
|
111
|
+
def test_renders_default_values_for_string_options(self):
|
|
112
|
+
p = argparse.ArgumentParser(prog="app")
|
|
113
|
+
p.add_argument("--format", default="json", help="Output format")
|
|
114
|
+
p.add_argument("--retries", default="3", help="Number of retries")
|
|
115
|
+
|
|
116
|
+
spec = convert_root(p)
|
|
117
|
+
format_flag = next(f for f in spec.flags if f.long == "format")
|
|
118
|
+
assert format_flag.default == ["json"]
|
|
119
|
+
|
|
120
|
+
retries_flag = next(f for f in spec.flags if f.long == "retries")
|
|
121
|
+
assert retries_flag.default == ["3"]
|
|
122
|
+
|
|
123
|
+
def test_renders_choices(self):
|
|
124
|
+
p = argparse.ArgumentParser(prog="app")
|
|
125
|
+
p.add_argument("--format", choices=["json", "yaml", "toml"], help="Output format")
|
|
126
|
+
|
|
127
|
+
spec = convert_root(p)
|
|
128
|
+
format_flag = next(f for f in spec.flags if f.long == "format")
|
|
129
|
+
assert format_flag.arg is not None
|
|
130
|
+
assert format_flag.arg.choices is not None
|
|
131
|
+
assert format_flag.arg.choices.values == ["json", "yaml", "toml"]
|
|
132
|
+
|
|
133
|
+
def test_renders_long_only_flags(self):
|
|
134
|
+
p = argparse.ArgumentParser(prog="app")
|
|
135
|
+
p.add_argument("--verbose", action="store_true", help="Be verbose")
|
|
136
|
+
|
|
137
|
+
output = generate(p)
|
|
138
|
+
|
|
139
|
+
assert "flag --verbose" in output
|
|
140
|
+
|
|
141
|
+
def test_renders_short_only_flags(self):
|
|
142
|
+
p = argparse.ArgumentParser(prog="app")
|
|
143
|
+
p.add_argument("-v", action="store_true", help="Be verbose")
|
|
144
|
+
|
|
145
|
+
output = generate(p)
|
|
146
|
+
|
|
147
|
+
assert "flag -v" in output
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class TestBooleanFlags:
|
|
151
|
+
def test_does_not_render_arg_for_boolean_flags(self):
|
|
152
|
+
p = argparse.ArgumentParser(prog="app")
|
|
153
|
+
p.add_argument("--force", action="store_true", help="Force the operation")
|
|
154
|
+
|
|
155
|
+
spec = convert_root(p)
|
|
156
|
+
force_flag = next(f for f in spec.flags if f.long == "force")
|
|
157
|
+
assert force_flag.arg is None
|
|
158
|
+
|
|
159
|
+
def test_renders_default_true_for_boolean_flags(self):
|
|
160
|
+
p = argparse.ArgumentParser(prog="app")
|
|
161
|
+
p.add_argument("--color", action="store_true", default=True, help="Enable color")
|
|
162
|
+
|
|
163
|
+
spec = convert_root(p)
|
|
164
|
+
color_flag = next(f for f in spec.flags if f.long == "color")
|
|
165
|
+
assert color_flag.default_bool is True
|
|
166
|
+
|
|
167
|
+
def test_skips_false_default_for_boolean_flags(self):
|
|
168
|
+
p = argparse.ArgumentParser(prog="app")
|
|
169
|
+
p.add_argument("--force", action="store_true", default=False, help="Force")
|
|
170
|
+
|
|
171
|
+
spec = convert_root(p)
|
|
172
|
+
force_flag = next(f for f in spec.flags if f.long == "force")
|
|
173
|
+
assert force_flag.default == []
|
|
174
|
+
assert force_flag.default_bool is None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class TestNegatedOptions:
|
|
178
|
+
def test_renders_negate_for_store_false(self):
|
|
179
|
+
p = argparse.ArgumentParser(prog="app")
|
|
180
|
+
p.add_argument("--no-color", action="store_false", help="Disable color output")
|
|
181
|
+
|
|
182
|
+
output = generate(p)
|
|
183
|
+
|
|
184
|
+
assert "negate=--no-color" in output
|
|
185
|
+
|
|
186
|
+
def test_negated_boolean_options_do_not_have_arg(self):
|
|
187
|
+
p = argparse.ArgumentParser(prog="app")
|
|
188
|
+
p.add_argument("--no-color", action="store_false", help="Disable color output")
|
|
189
|
+
|
|
190
|
+
spec = convert_root(p)
|
|
191
|
+
color_flag = next(f for f in spec.flags if f.long == "no-color")
|
|
192
|
+
assert color_flag.negate == "--no-color"
|
|
193
|
+
assert color_flag.arg is None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class TestEnvironmentVariables:
|
|
197
|
+
def test_no_native_env_support_in_argparse(self):
|
|
198
|
+
p = argparse.ArgumentParser(prog="app")
|
|
199
|
+
p.add_argument("--color", help="Color output")
|
|
200
|
+
|
|
201
|
+
spec = convert_root(p)
|
|
202
|
+
color_flag = next(f for f in spec.flags if f.long == "color")
|
|
203
|
+
assert color_flag.env == ""
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class TestSkipsBuiltinFlags:
|
|
207
|
+
def test_does_not_render_help_and_version_flags(self):
|
|
208
|
+
p = argparse.ArgumentParser(prog="app")
|
|
209
|
+
p.add_argument("--version", action="version", version="1.0.0")
|
|
210
|
+
p.add_argument("--custom", help="A custom flag")
|
|
211
|
+
|
|
212
|
+
output = generate(p)
|
|
213
|
+
|
|
214
|
+
assert "flag --help" not in output
|
|
215
|
+
assert "flag --version" not in output
|
|
216
|
+
assert "flag --custom" in output
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class TestPositionalArguments:
|
|
220
|
+
def test_converts_required_arguments(self):
|
|
221
|
+
p = argparse.ArgumentParser(prog="cmd")
|
|
222
|
+
p.add_argument("file", help="Input file")
|
|
223
|
+
|
|
224
|
+
output = generate(p)
|
|
225
|
+
|
|
226
|
+
assert "arg <file>" in output
|
|
227
|
+
assert 'help="Input file"' in output
|
|
228
|
+
|
|
229
|
+
def test_converts_optional_arguments(self):
|
|
230
|
+
p = argparse.ArgumentParser(prog="cmd")
|
|
231
|
+
p.add_argument("name", nargs="?", help="Optional name")
|
|
232
|
+
|
|
233
|
+
output = generate(p)
|
|
234
|
+
|
|
235
|
+
assert "arg [name]" in output
|
|
236
|
+
assert "required=#false" in output
|
|
237
|
+
|
|
238
|
+
def test_converts_variadic_arguments(self):
|
|
239
|
+
p = argparse.ArgumentParser(prog="cmd")
|
|
240
|
+
p.add_argument("files", nargs="+", help="Multiple files")
|
|
241
|
+
|
|
242
|
+
output = generate(p)
|
|
243
|
+
|
|
244
|
+
assert "arg <files>" in output
|
|
245
|
+
assert "var=#true" in output
|
|
246
|
+
|
|
247
|
+
def test_converts_mixed_required_and_optional_args(self):
|
|
248
|
+
p = argparse.ArgumentParser(prog="cmd")
|
|
249
|
+
p.add_argument("source", help="Source path")
|
|
250
|
+
p.add_argument("dest", nargs="?", help="Destination path")
|
|
251
|
+
|
|
252
|
+
output = generate(p)
|
|
253
|
+
|
|
254
|
+
assert "arg <source>" in output
|
|
255
|
+
assert "arg [dest]" in output
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class TestChoices:
|
|
259
|
+
def test_renders_choices_for_option_arguments(self):
|
|
260
|
+
p = argparse.ArgumentParser(prog="deploy")
|
|
261
|
+
p.add_argument("--format", choices=["json", "yaml", "toml"], help="Output format")
|
|
262
|
+
|
|
263
|
+
output = generate(p)
|
|
264
|
+
|
|
265
|
+
assert "choices" in output
|
|
266
|
+
assert "json" in output
|
|
267
|
+
assert "yaml" in output
|
|
268
|
+
assert "toml" in output
|
|
269
|
+
|
|
270
|
+
def test_renders_choices_for_positional_arguments(self):
|
|
271
|
+
p = argparse.ArgumentParser(prog="deploy")
|
|
272
|
+
p.add_argument("env", choices=["dev", "staging", "prod"], help="Environment")
|
|
273
|
+
|
|
274
|
+
output = generate(p)
|
|
275
|
+
|
|
276
|
+
assert "arg <env>" in output
|
|
277
|
+
assert "choices" in output
|
|
278
|
+
assert "dev" in output
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
class TestHiddenOptions:
|
|
282
|
+
def test_hides_options_with_suppress_help(self):
|
|
283
|
+
p = argparse.ArgumentParser(prog="app")
|
|
284
|
+
p.add_argument("--secret", help=argparse.SUPPRESS)
|
|
285
|
+
|
|
286
|
+
spec = convert_root(p)
|
|
287
|
+
secret_flag = next(f for f in spec.flags if f.long == "secret")
|
|
288
|
+
assert secret_flag.hide is True
|
|
289
|
+
assert secret_flag.help == ""
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class TestLongHelp:
|
|
293
|
+
def test_uses_description_as_about_when_only_description(self):
|
|
294
|
+
p = argparse.ArgumentParser(prog="app", description="Just a description")
|
|
295
|
+
spec = convert_root(p)
|
|
296
|
+
assert spec.about == "Just a description"
|
|
297
|
+
assert spec.long == ""
|
|
298
|
+
|
|
299
|
+
def test_uses_epilog_as_long_when_both_description_and_epilog(self):
|
|
300
|
+
p = argparse.ArgumentParser(
|
|
301
|
+
prog="app",
|
|
302
|
+
description="Short description",
|
|
303
|
+
epilog="This is a much longer explanation of the app.",
|
|
304
|
+
)
|
|
305
|
+
spec = convert_root(p)
|
|
306
|
+
assert spec.about == "Short description"
|
|
307
|
+
assert spec.long == "This is a much longer explanation of the app."
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
class TestOptionalOptionArgument:
|
|
311
|
+
def test_renders_flag_with_optional_argument(self):
|
|
312
|
+
p = argparse.ArgumentParser(prog="app")
|
|
313
|
+
p.add_argument("--cheese", nargs="?", help="Add cheese")
|
|
314
|
+
|
|
315
|
+
spec = convert_root(p)
|
|
316
|
+
cheese_flag = next(f for f in spec.flags if f.long == "cheese")
|
|
317
|
+
assert cheese_flag.arg is not None
|
|
318
|
+
assert cheese_flag.arg.required is False
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
class TestSkipsBuiltinCommands:
|
|
322
|
+
def test_does_not_render_help_subcommand(self):
|
|
323
|
+
p = argparse.ArgumentParser(prog="app")
|
|
324
|
+
sub = p.add_subparsers()
|
|
325
|
+
sub.add_parser("run", help="Run something")
|
|
326
|
+
|
|
327
|
+
output = generate(p)
|
|
328
|
+
assert "cmd run" in output
|
|
329
|
+
# argparse doesn't add a "help" subcommand by default,
|
|
330
|
+
# but ensure no help-related cmd leaks
|
|
331
|
+
assert "cmd help" not in output
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
class TestSubcommandRequired:
|
|
335
|
+
def test_sets_subcommand_required_for_commands_with_subcommands(self):
|
|
336
|
+
p = argparse.ArgumentParser(prog="app")
|
|
337
|
+
sub = p.add_subparsers()
|
|
338
|
+
config = sub.add_parser("config", help="Manage config")
|
|
339
|
+
config_sub = config.add_subparsers()
|
|
340
|
+
config_sub.add_parser("get", help="Get a value")
|
|
341
|
+
config_sub.add_parser("set", help="Set a value")
|
|
342
|
+
|
|
343
|
+
output = generate(p)
|
|
344
|
+
|
|
345
|
+
assert "subcommand_required=#true" in output
|
|
346
|
+
|
|
347
|
+
def test_does_not_set_subcommand_required_when_command_has_positional_args(self):
|
|
348
|
+
p = argparse.ArgumentParser(prog="app")
|
|
349
|
+
sub = p.add_subparsers()
|
|
350
|
+
greet = sub.add_parser("greet", help="Greet someone")
|
|
351
|
+
greet.add_argument("name", help="Name to greet")
|
|
352
|
+
greet_sub = greet.add_subparsers()
|
|
353
|
+
greet_sub.add_parser("formally", help="Formal greeting")
|
|
354
|
+
|
|
355
|
+
spec = convert_root(p)
|
|
356
|
+
greet_cmd = next(c for c in spec.cmds if c.name == "greet")
|
|
357
|
+
assert not greet_cmd.subcommand_required
|
|
358
|
+
|
|
359
|
+
def test_does_not_set_subcommand_required_for_runnable_commands(self):
|
|
360
|
+
p = argparse.ArgumentParser(prog="app")
|
|
361
|
+
sub = p.add_subparsers()
|
|
362
|
+
task = sub.add_parser("task", help="Run a task")
|
|
363
|
+
task.set_defaults(func=lambda: None) # runnable via func
|
|
364
|
+
task_sub = task.add_subparsers()
|
|
365
|
+
task_sub.add_parser("list", help="List tasks")
|
|
366
|
+
|
|
367
|
+
# argparse doesn't have a concept of "runnable" like commander,
|
|
368
|
+
# but when a command has positional args, it's not subcommand_required
|
|
369
|
+
# Here the task has subcommands and no positional args, so it IS subcommand_required
|
|
370
|
+
# This is the argparse equivalent behavior
|
|
371
|
+
spec = convert_root(p)
|
|
372
|
+
task_cmd = next(c for c in spec.cmds if c.name == "task")
|
|
373
|
+
assert task_cmd.subcommand_required is True
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
class TestDefaultValues:
|
|
377
|
+
def test_renders_default_value_for_argument(self):
|
|
378
|
+
p = argparse.ArgumentParser(prog="app")
|
|
379
|
+
p.add_argument("file", nargs="?", default="input.txt", help="Input file")
|
|
380
|
+
|
|
381
|
+
output = generate(p)
|
|
382
|
+
|
|
383
|
+
assert "default=input.txt" in output
|
|
384
|
+
|
|
385
|
+
def test_preserves_string_zero_as_default(self):
|
|
386
|
+
p = argparse.ArgumentParser(prog="app")
|
|
387
|
+
p.add_argument("--port", default="0", help="Port number")
|
|
388
|
+
|
|
389
|
+
output = generate(p)
|
|
390
|
+
|
|
391
|
+
assert "default=0" in output
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
class TestCountFlags:
|
|
395
|
+
def test_renders_count_flags(self):
|
|
396
|
+
p = argparse.ArgumentParser(prog="app")
|
|
397
|
+
p.add_argument("-v", "--verbose", action="count", default=0, help="Verbosity level")
|
|
398
|
+
|
|
399
|
+
spec = convert_root(p)
|
|
400
|
+
verbose_flag = next(f for f in spec.flags if f.long == "verbose")
|
|
401
|
+
assert verbose_flag.count is True
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
class TestSpecialCharacterEscaping:
|
|
405
|
+
def test_handles_special_chars_in_flag_help(self):
|
|
406
|
+
p = argparse.ArgumentParser(prog="app")
|
|
407
|
+
p.add_argument("--format", help="Output format:\n json\n yaml")
|
|
408
|
+
|
|
409
|
+
output = generate(p)
|
|
410
|
+
assert "help=" in output
|
|
411
|
+
kdl_content = output.split("\n", 1)[1] if output.startswith("//") else output
|
|
412
|
+
validate_kdl(kdl_content)
|
|
413
|
+
|
|
414
|
+
def test_handles_newlines_in_command_description(self):
|
|
415
|
+
p = argparse.ArgumentParser(
|
|
416
|
+
prog="app",
|
|
417
|
+
description="First line.\nSecond line.\n\nThird paragraph.",
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
output = generate(p)
|
|
421
|
+
assert "about" in output
|
|
422
|
+
kdl_content = output.split("\n", 1)[1] if output.startswith("//") else output
|
|
423
|
+
validate_kdl(kdl_content)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
class TestKDLRoundTrip:
|
|
427
|
+
def test_generates_valid_kdl_for_basic_command(self):
|
|
428
|
+
p = argparse.ArgumentParser(prog="app", description="A CLI")
|
|
429
|
+
p.add_argument("-v", "--verbose", action="store_true", help="Be verbose")
|
|
430
|
+
p.add_argument("-f", "--file", help="Input file")
|
|
431
|
+
|
|
432
|
+
output = generate(p)
|
|
433
|
+
kdl_content = output.split("\n", 1)[1] if output.startswith("//") else output
|
|
434
|
+
validate_kdl(kdl_content)
|
|
435
|
+
|
|
436
|
+
def test_generates_valid_kdl_for_nested_commands(self):
|
|
437
|
+
p = argparse.ArgumentParser(prog="app")
|
|
438
|
+
sub = p.add_subparsers()
|
|
439
|
+
config = sub.add_parser("config", help="Config")
|
|
440
|
+
config_sub = config.add_subparsers()
|
|
441
|
+
config_sub.add_parser("get", help="Get value")
|
|
442
|
+
|
|
443
|
+
output = generate(p)
|
|
444
|
+
kdl_content = output.split("\n", 1)[1] if output.startswith("//") else output
|
|
445
|
+
validate_kdl(kdl_content)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
class TestGenerateKDL:
|
|
449
|
+
def test_is_alias_for_generate(self):
|
|
450
|
+
p = argparse.ArgumentParser(prog="app", description="A CLI")
|
|
451
|
+
assert generate_kdl(p) == generate(p)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
class TestConvertRoot:
|
|
455
|
+
def test_returns_spec_with_correct_structure(self):
|
|
456
|
+
p = argparse.ArgumentParser(prog="mycli", description="A CLI tool")
|
|
457
|
+
p.add_argument("--verbose", action="store_true", help="Be verbose")
|
|
458
|
+
|
|
459
|
+
spec = convert_root(p)
|
|
460
|
+
|
|
461
|
+
assert spec.name == "mycli"
|
|
462
|
+
assert spec.bin == "mycli"
|
|
463
|
+
assert spec.about == "A CLI tool"
|
|
464
|
+
assert len(spec.flags) == 1
|
|
465
|
+
assert spec.flags[0].long == "verbose"
|
|
466
|
+
|
|
467
|
+
def test_includes_arguments_in_spec(self):
|
|
468
|
+
p = argparse.ArgumentParser(prog="cmd")
|
|
469
|
+
p.add_argument("file", help="Input file")
|
|
470
|
+
p.add_argument("output", nargs="?", help="Output file")
|
|
471
|
+
|
|
472
|
+
spec = convert_root(p)
|
|
473
|
+
|
|
474
|
+
assert len(spec.args) == 2
|
|
475
|
+
assert spec.args[0].name == "file"
|
|
476
|
+
assert spec.args[0].required is True
|
|
477
|
+
assert spec.args[1].name == "output"
|
|
478
|
+
assert spec.args[1].required is False
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
class TestJSONOutput:
|
|
482
|
+
def test_generates_json_output_with_correct_structure(self):
|
|
483
|
+
p = argparse.ArgumentParser(prog="mycli", description="A CLI tool")
|
|
484
|
+
p.add_argument("--verbose", action="store_true", help="Be verbose")
|
|
485
|
+
sub = p.add_subparsers()
|
|
486
|
+
sub.add_parser("run", help="Run something")
|
|
487
|
+
|
|
488
|
+
j = generate_json(p)
|
|
489
|
+
parsed = json.loads(j)
|
|
490
|
+
|
|
491
|
+
assert parsed["name"] == "mycli"
|
|
492
|
+
assert parsed["bin"] == "mycli"
|
|
493
|
+
assert parsed["about"] == "A CLI tool"
|
|
494
|
+
assert len(parsed["flags"]) == 1
|
|
495
|
+
assert len(parsed["cmds"]) == 1
|
|
496
|
+
assert parsed["cmds"][0]["name"] == "run"
|
|
497
|
+
|
|
498
|
+
def test_includes_choices_in_json_output(self):
|
|
499
|
+
p = argparse.ArgumentParser(prog="deploy")
|
|
500
|
+
p.add_argument("env", choices=["dev", "prod"], help="Environment")
|
|
501
|
+
|
|
502
|
+
j = generate_json(p)
|
|
503
|
+
parsed = json.loads(j)
|
|
504
|
+
|
|
505
|
+
assert parsed["args"][0]["choices"] == ["dev", "prod"]
|
|
506
|
+
|
|
507
|
+
def test_includes_flag_details_in_json(self):
|
|
508
|
+
p = argparse.ArgumentParser(prog="app")
|
|
509
|
+
p.add_argument("--env", required=True, help="Target environment")
|
|
510
|
+
p.add_argument("--no-color", action="store_false", help="Disable color")
|
|
511
|
+
|
|
512
|
+
j = generate_json(p)
|
|
513
|
+
parsed = json.loads(j)
|
|
514
|
+
|
|
515
|
+
assert len(parsed["flags"]) == 2
|
|
516
|
+
env_flag = next(f for f in parsed["flags"] if f["name"] == "--env")
|
|
517
|
+
assert env_flag["required"] is True
|
|
518
|
+
assert env_flag["arg"] is not None
|
|
519
|
+
|
|
520
|
+
color_flag = next(f for f in parsed["flags"] if f["name"] == "--no-color")
|
|
521
|
+
assert color_flag["negate"] == "--no-color"
|
|
522
|
+
|
|
523
|
+
def test_handles_custom_bin_name_in_json(self):
|
|
524
|
+
p = argparse.ArgumentParser(prog="app", description="A tool")
|
|
525
|
+
j = generate_json(p, "my-tool")
|
|
526
|
+
parsed = json.loads(j)
|
|
527
|
+
|
|
528
|
+
assert parsed["bin"] == "my-tool"
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
class TestEdgeCases:
|
|
532
|
+
def test_handles_empty_command_with_no_options_or_args(self):
|
|
533
|
+
p = argparse.ArgumentParser(prog="empty")
|
|
534
|
+
output = generate(p)
|
|
535
|
+
|
|
536
|
+
assert "name empty" in output
|
|
537
|
+
assert "bin empty" in output
|
|
538
|
+
# Skip the comment line and check no flag/arg/cmd in actual content
|
|
539
|
+
content = output.split("\n", 1)[1] if output.startswith("//") else output
|
|
540
|
+
assert "flag" not in content
|
|
541
|
+
assert "arg " not in content
|
|
542
|
+
assert "cmd" not in content
|
|
543
|
+
|
|
544
|
+
def test_handles_command_with_only_subcommands(self):
|
|
545
|
+
p = argparse.ArgumentParser(prog="app")
|
|
546
|
+
sub = p.add_subparsers()
|
|
547
|
+
sub.add_parser("start", help="Start")
|
|
548
|
+
sub.add_parser("stop", help="Stop")
|
|
549
|
+
|
|
550
|
+
output = generate(p)
|
|
551
|
+
|
|
552
|
+
assert "cmd start" in output
|
|
553
|
+
assert "cmd stop" in output
|
|
554
|
+
|
|
555
|
+
def test_handles_variadic_option_argument(self):
|
|
556
|
+
p = argparse.ArgumentParser(prog="app")
|
|
557
|
+
p.add_argument("--include", nargs="+", help="Include patterns")
|
|
558
|
+
|
|
559
|
+
spec = convert_root(p)
|
|
560
|
+
include_flag = next(f for f in spec.flags if f.long == "include")
|
|
561
|
+
|
|
562
|
+
assert include_flag.var is True
|
|
563
|
+
assert include_flag.arg is not None
|
|
564
|
+
assert include_flag.arg.var is True
|
|
565
|
+
|
|
566
|
+
def test_handles_multiple_flags_on_subcommand(self):
|
|
567
|
+
p = argparse.ArgumentParser(prog="app")
|
|
568
|
+
sub = p.add_subparsers()
|
|
569
|
+
build = sub.add_parser("build", help="Build the project")
|
|
570
|
+
build.add_argument("-o", "--output", help="Output directory")
|
|
571
|
+
build.add_argument("--minify", action="store_true", help="Minify output")
|
|
572
|
+
build.add_argument("--watch", action="store_true", help="Watch for changes")
|
|
573
|
+
|
|
574
|
+
output = generate(p)
|
|
575
|
+
|
|
576
|
+
assert 'flag "-o --output"' in output
|
|
577
|
+
assert "flag --minify" in output
|
|
578
|
+
assert "flag --watch" in output
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
class TestFullOutputFormat:
|
|
582
|
+
def test_matches_expected_kdl_output(self):
|
|
583
|
+
p = argparse.ArgumentParser(prog="example", description="An example CLI")
|
|
584
|
+
p.add_argument("-f", "--file", help="Some input file")
|
|
585
|
+
p.add_argument("--verbose", action="store_true", help="Enable verbose output")
|
|
586
|
+
|
|
587
|
+
output = generate(p, "example")
|
|
588
|
+
|
|
589
|
+
assert "name example" in output
|
|
590
|
+
assert "bin example" in output
|
|
591
|
+
assert 'about "An example CLI"' in output
|
|
592
|
+
assert 'flag "-f --file"' in output
|
|
593
|
+
assert "flag --verbose" in output
|