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.
@@ -0,0 +1,8 @@
1
+ node_modules/
2
+ dist/
3
+ aube-lock.yaml
4
+ .npmrc
5
+ .venv/
6
+ __pycache__/
7
+ *.pyc
8
+ .pytest_cache/
@@ -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