usage-spec-click 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,93 @@
1
+ Metadata-Version: 2.4
2
+ Name: usage-spec-click
3
+ Version: 1.0.0
4
+ Summary: Generates usage spec for CLIs written with click
5
+ License-Expression: MIT
6
+ Keywords: cli,click,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: click>=8.0
17
+ Requires-Dist: usage-spec
18
+ Provides-Extra: dev
19
+ Requires-Dist: click>=8.0; extra == 'dev'
20
+ Requires-Dist: pytest>=8.0; extra == 'dev'
21
+ Description-Content-Type: text/markdown
22
+
23
+ # usage-spec-click
24
+
25
+ Generates [usage spec](https://usage.jdx.dev) for CLIs written with [click](https://click.palletsprojects.com/).
26
+
27
+ ## Install
28
+
29
+ ```sh
30
+ pip install usage-spec-click
31
+ ```
32
+
33
+ ## Usage
34
+
35
+ ```python
36
+ import click
37
+ from click_usage import generate
38
+
39
+ @click.command()
40
+ @click.version_option("1.0.0")
41
+ @click.option("-v", "--verbose", is_flag=True, help="Enable verbose output")
42
+ @click.option("-f", "--file", type=str, help="Input file")
43
+ @click.option("--color/--no-color", default=False, help="Color output")
44
+ def cli(verbose, file, color):
45
+ pass
46
+
47
+ print(generate(cli, "mycli"))
48
+ ```
49
+
50
+ ## API
51
+
52
+ ### `generate(cmd, bin_name=None)`
53
+
54
+ Generates a usage spec in KDL format from a click `Command` or `Group`.
55
+
56
+ ### `generate_kdl(cmd, bin_name=None)`
57
+
58
+ Alias for `generate()`.
59
+
60
+ ### `generate_json(cmd, bin_name=None)`
61
+
62
+ Generates a usage spec in JSON format.
63
+
64
+ ### `convert_root(cmd, bin_name=None)`
65
+
66
+ Converts a click `Command` or `Group` to the `Spec` data structure.
67
+
68
+ ## Supported Features
69
+
70
+ | click Feature | Usage Spec Mapping |
71
+ |---|---|
72
+ | `cmd.name` | `name` / `bin` |
73
+ | `@click.version_option()` | `version` |
74
+ | `cmd.short_help` / `cmd.help` | `about` / `long_about` |
75
+ | `@click.option()` | `flag` |
76
+ | `required=True` | `flag required=#true` |
77
+ | `is_flag=True` | Boolean flag (no arg) |
78
+ | `--flag/--no-flag` | `negate` |
79
+ | `count=True` | `count=#true` |
80
+ | `multiple=True` | `var=#true` |
81
+ | `type=click.Choice()` | `choices` |
82
+ | `default=...` | `default` |
83
+ | `hidden=True` | `hide=#true` |
84
+ | `deprecated="..."` | `deprecated` |
85
+ | `envvar="..."` | `env` |
86
+ | `@click.argument()` | `arg` |
87
+ | `@click.group()` subcommands | `cmd` (recursive) |
88
+ | Groups without positional args | `subcommand_required=#true` |
89
+ | `cmd.hidden` | Commands hidden from listing |
90
+
91
+ ## License
92
+
93
+ MIT
@@ -0,0 +1,71 @@
1
+ # usage-spec-click
2
+
3
+ Generates [usage spec](https://usage.jdx.dev) for CLIs written with [click](https://click.palletsprojects.com/).
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ pip install usage-spec-click
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```python
14
+ import click
15
+ from click_usage import generate
16
+
17
+ @click.command()
18
+ @click.version_option("1.0.0")
19
+ @click.option("-v", "--verbose", is_flag=True, help="Enable verbose output")
20
+ @click.option("-f", "--file", type=str, help="Input file")
21
+ @click.option("--color/--no-color", default=False, help="Color output")
22
+ def cli(verbose, file, color):
23
+ pass
24
+
25
+ print(generate(cli, "mycli"))
26
+ ```
27
+
28
+ ## API
29
+
30
+ ### `generate(cmd, bin_name=None)`
31
+
32
+ Generates a usage spec in KDL format from a click `Command` or `Group`.
33
+
34
+ ### `generate_kdl(cmd, bin_name=None)`
35
+
36
+ Alias for `generate()`.
37
+
38
+ ### `generate_json(cmd, bin_name=None)`
39
+
40
+ Generates a usage spec in JSON format.
41
+
42
+ ### `convert_root(cmd, bin_name=None)`
43
+
44
+ Converts a click `Command` or `Group` to the `Spec` data structure.
45
+
46
+ ## Supported Features
47
+
48
+ | click Feature | Usage Spec Mapping |
49
+ |---|---|
50
+ | `cmd.name` | `name` / `bin` |
51
+ | `@click.version_option()` | `version` |
52
+ | `cmd.short_help` / `cmd.help` | `about` / `long_about` |
53
+ | `@click.option()` | `flag` |
54
+ | `required=True` | `flag required=#true` |
55
+ | `is_flag=True` | Boolean flag (no arg) |
56
+ | `--flag/--no-flag` | `negate` |
57
+ | `count=True` | `count=#true` |
58
+ | `multiple=True` | `var=#true` |
59
+ | `type=click.Choice()` | `choices` |
60
+ | `default=...` | `default` |
61
+ | `hidden=True` | `hide=#true` |
62
+ | `deprecated="..."` | `deprecated` |
63
+ | `envvar="..."` | `env` |
64
+ | `@click.argument()` | `arg` |
65
+ | `@click.group()` subcommands | `cmd` (recursive) |
66
+ | Groups without positional args | `subcommand_required=#true` |
67
+ | `cmd.hidden` | Commands hidden from listing |
68
+
69
+ ## License
70
+
71
+ MIT
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "usage-spec-click"
7
+ version = "1.0.0"
8
+ description = "Generates usage spec for CLIs written with click"
9
+ requires-python = ">=3.10"
10
+ license = "MIT"
11
+ readme = "README.md"
12
+ keywords = ["click", "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", "click>=8.0"]
24
+
25
+ [project.optional-dependencies]
26
+ dev = ["pytest>=8.0", "click>=8.0"]
27
+
28
+ [tool.hatch.build.targets.wheel]
29
+ packages = ["src/click_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 click."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
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(cmd: click.BaseCommand, bin_name: str | None = None) -> str:
26
+ """Generate usage spec in KDL format from a click Command/Group."""
27
+ spec = convert_root(cmd, bin_name)
28
+ return core_generate(spec, format="kdl", comment="@generated by usage-spec-click from click metadata")
29
+
30
+
31
+ def generate_kdl(cmd: click.BaseCommand, bin_name: str | None = None) -> str:
32
+ """Generate usage spec in KDL format (alias for generate)."""
33
+ return generate(cmd, bin_name)
34
+
35
+
36
+ def generate_json(cmd: click.BaseCommand, bin_name: str | None = None) -> str:
37
+ """Generate usage spec in JSON format from a click Command/Group."""
38
+ spec = convert_root(cmd, bin_name)
39
+ return core_generate(spec, format="json")
@@ -0,0 +1,216 @@
1
+ """Convert click commands to usage spec."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import click
8
+ from usage_spec import Spec, SpecArg, SpecFlag, SpecCommand, SpecChoices
9
+
10
+
11
+ def _convert_arg(arg: click.Argument) -> SpecArg:
12
+ variadic = arg.nargs == -1
13
+ required = arg.required
14
+
15
+ from enum import Enum
16
+ _has_real_default = arg.default is not None and not isinstance(arg.default, Enum)
17
+
18
+ result = SpecArg(
19
+ name=arg.name or arg.human_readable_name.lower(),
20
+ help=getattr(arg, "help", "") or "",
21
+ required=required,
22
+ var=variadic,
23
+ hide=getattr(arg, "hidden", False),
24
+ default=[str(arg.default)] if _has_real_default else [],
25
+ choices=None,
26
+ )
27
+
28
+ if isinstance(arg.type, click.Choice):
29
+ result.choices = SpecChoices(values=list(arg.type.choices))
30
+
31
+ return result
32
+
33
+
34
+ def _extract_short_long(opts: list[str]) -> tuple[str, str]:
35
+ short = ""
36
+ long = ""
37
+ for opt in opts:
38
+ if opt.startswith("--"):
39
+ long = opt[2:]
40
+ elif opt.startswith("-"):
41
+ short = opt[1:]
42
+ return short, long
43
+
44
+
45
+ def _convert_flag(opt: click.Option) -> SpecFlag:
46
+ short, long = _extract_short_long(opt.opts)
47
+
48
+ is_bool = opt.is_bool_flag or (opt.is_flag and not opt.multiple and not opt.count)
49
+
50
+ # Detect negation from secondary opts (--flag/--no-flag pattern)
51
+ negate = ""
52
+ if opt.secondary_opts:
53
+ for sec in opt.secondary_opts:
54
+ if sec.startswith("--no-"):
55
+ # negate points to the negative form itself
56
+ negate = sec
57
+ break
58
+
59
+ flag = SpecFlag(
60
+ short=short,
61
+ long=long,
62
+ help=opt.help or "",
63
+ help_long="",
64
+ required=opt.required,
65
+ hide=opt.hidden,
66
+ global_=False,
67
+ count=opt.count,
68
+ var=opt.multiple,
69
+ negate=negate,
70
+ deprecated=str(opt.deprecated) if isinstance(opt.deprecated, str) else "",
71
+ default=[],
72
+ default_bool=None,
73
+ env=str(opt.envvar) if isinstance(opt.envvar, str) else (opt.envvar[0] if opt.envvar and isinstance(opt.envvar, (list, tuple)) else ""),
74
+ arg=None,
75
+ )
76
+
77
+ # Non-boolean options have an argument
78
+ if not is_bool and not opt.count:
79
+ arg_name = (long or short).replace("-", "_").upper()
80
+ flag.arg = SpecArg(
81
+ name=arg_name,
82
+ help="",
83
+ required=opt.required,
84
+ var=opt.multiple,
85
+ hide=False,
86
+ default=[],
87
+ choices=None,
88
+ )
89
+
90
+ if isinstance(opt.type, click.Choice):
91
+ flag.arg.choices = SpecChoices(values=list(opt.type.choices))
92
+
93
+ # Default values - click uses Sentinel enum for unset defaults
94
+ from enum import Enum
95
+ _has_real_default = opt.default is not None and not isinstance(opt.default, Enum)
96
+
97
+ if _has_real_default and not (is_bool and opt.default is False):
98
+ if is_bool or opt.count:
99
+ if opt.default is True:
100
+ flag.default_bool = True
101
+ else:
102
+ flag.default = [str(opt.default)]
103
+
104
+ return flag
105
+
106
+
107
+ def _convert_command(cmd: click.BaseCommand) -> SpecCommand:
108
+ is_group = isinstance(cmd, click.Group)
109
+
110
+ sc = SpecCommand(
111
+ name=cmd.name or "",
112
+ help=cmd.short_help or cmd.help or "",
113
+ help_long=cmd.short_help and cmd.help and cmd.help or "",
114
+ hide=cmd.hidden,
115
+ deprecated=str(cmd.deprecated) if isinstance(cmd.deprecated, str) else "",
116
+ aliases=[],
117
+ subcommand_required=False,
118
+ flags=[],
119
+ args=[],
120
+ cmds=[],
121
+ )
122
+
123
+ for param in cmd.params:
124
+ if isinstance(param, click.Option):
125
+ # Skip help and version flags
126
+ name = param.name or ""
127
+ if name in ("help", "version"):
128
+ continue
129
+ sc.flags.append(_convert_flag(param))
130
+ elif isinstance(param, click.Argument):
131
+ sc.args.append(_convert_arg(param))
132
+
133
+ # Subcommands for Groups
134
+ if is_group:
135
+ group = cmd # type: click.Group
136
+ # Try eager commands dict first, then lazy loading
137
+ if group.commands:
138
+ for sub_name, sub_cmd in group.commands.items():
139
+ if sub_cmd.hidden:
140
+ continue
141
+ sc.cmds.append(_convert_command(sub_cmd))
142
+ else:
143
+ # Lazy loading via list_commands + get_command
144
+ ctx = click.Context(cmd)
145
+ for sub_name in group.list_commands(ctx):
146
+ sub_cmd = group.get_command(ctx, sub_name)
147
+ if sub_cmd and not sub_cmd.hidden:
148
+ sc.cmds.append(_convert_command(sub_cmd))
149
+
150
+ # subcommand_required: group with no positional args
151
+ if sc.cmds and not sc.args:
152
+ sc.subcommand_required = True
153
+
154
+ return sc
155
+
156
+
157
+ def _extract_version_from_callback(opt: click.Option) -> str:
158
+ """Extract version string from click.version_option callback closure."""
159
+ cb = opt.callback
160
+ if not cb or not cb.__closure__:
161
+ return ""
162
+ for cell in cb.__closure__:
163
+ val = cell.cell_contents
164
+ if isinstance(val, str) and val and val[0].isdigit():
165
+ return val
166
+ return ""
167
+
168
+
169
+ def convert_root(cmd: click.BaseCommand, bin_name: str | None = None) -> Spec:
170
+ """Convert a click Command or Group to a Spec object."""
171
+ name = bin_name or cmd.name or ""
172
+
173
+ spec = Spec(
174
+ name=name,
175
+ bin=name,
176
+ version="",
177
+ about=cmd.short_help or cmd.help or "",
178
+ long=cmd.short_help and cmd.help and cmd.help or "",
179
+ usage="",
180
+ flags=[],
181
+ args=[],
182
+ cmds=[],
183
+ )
184
+
185
+ for param in cmd.params:
186
+ if isinstance(param, click.Option):
187
+ pname = param.name or ""
188
+ # Version flag detection
189
+ if pname == "version":
190
+ version = _extract_version_from_callback(param)
191
+ if version:
192
+ spec.version = version
193
+ continue
194
+ if pname == "help":
195
+ continue
196
+ spec.flags.append(_convert_flag(param))
197
+ elif isinstance(param, click.Argument):
198
+ spec.args.append(_convert_arg(param))
199
+
200
+ # Subcommands
201
+ is_group = isinstance(cmd, click.Group)
202
+ if is_group:
203
+ group = cmd # type: click.Group
204
+ if group.commands:
205
+ for sub_name, sub_cmd in group.commands.items():
206
+ if sub_cmd.hidden:
207
+ continue
208
+ spec.cmds.append(_convert_command(sub_cmd))
209
+ else:
210
+ ctx = click.Context(cmd)
211
+ for sub_name in group.list_commands(ctx):
212
+ sub_cmd = group.get_command(ctx, sub_name)
213
+ if sub_cmd and not sub_cmd.hidden:
214
+ spec.cmds.append(_convert_command(sub_cmd))
215
+
216
+ return spec
@@ -0,0 +1,813 @@
1
+ """Tests for click usage spec integration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Optional
7
+
8
+ import click
9
+ from click_usage import generate, generate_kdl, generate_json, convert_root
10
+ from usage_spec import validate_kdl
11
+
12
+
13
+ class TestSimpleCommand:
14
+ def test_generates_spec_for_basic_cli(self):
15
+ @click.command()
16
+ @click.option("-v", "--verbose", is_flag=True, help="Enable verbose output")
17
+ @click.option("-c", "--config", type=str, help="Config file path")
18
+ def cli(verbose, config):
19
+ pass
20
+
21
+ output = generate(cli, "mycli")
22
+
23
+ assert "name mycli" in output
24
+ assert "bin mycli" in output
25
+ assert 'flag "-v --verbose"' in output
26
+ assert 'help="Enable verbose output"' in output
27
+ assert 'flag "-c --config"' in output
28
+ assert 'help="Config file path"' in output
29
+
30
+ def test_includes_generated_comment_header(self):
31
+ @click.command()
32
+ def cli():
33
+ pass
34
+
35
+ output = generate(cli)
36
+ assert "// @generated by usage-spec-click from click metadata" in output
37
+
38
+ def test_uses_custom_bin_name(self):
39
+ @click.command()
40
+ def cli():
41
+ pass
42
+
43
+ output = generate(cli, "my-app")
44
+ assert "bin my-app" in output
45
+
46
+
47
+ class TestVersion:
48
+ def test_extracts_version_from_version_option(self):
49
+ @click.command()
50
+ @click.version_option("2.0.0")
51
+ def cli():
52
+ pass
53
+
54
+ spec = convert_root(cli)
55
+ assert spec.version == "2.0.0"
56
+
57
+ def test_skips_version_field_when_not_set(self):
58
+ @click.command()
59
+ def cli():
60
+ pass
61
+
62
+ output = generate(cli)
63
+ assert "version " not in output
64
+
65
+
66
+ class TestAboutDescription:
67
+ def test_extracts_short_help_as_about(self):
68
+ @click.command(short_help="A simple CLI tool")
69
+ def cli():
70
+ pass
71
+
72
+ spec = convert_root(cli)
73
+ assert spec.about == "A simple CLI tool"
74
+
75
+ def test_extracts_help_as_about_when_no_short_help(self):
76
+ @click.command(help="Just a description")
77
+ def cli():
78
+ pass
79
+
80
+ spec = convert_root(cli)
81
+ assert spec.about == "Just a description"
82
+
83
+ def test_sets_long_when_both_help_and_short_help(self):
84
+ @click.command(short_help="Short help", help="This is a much longer description of the app.")
85
+ def cli():
86
+ pass
87
+
88
+ spec = convert_root(cli)
89
+ assert spec.about == "Short help"
90
+ assert spec.long == "This is a much longer description of the app."
91
+
92
+
93
+ class TestNestedSubcommands:
94
+ def test_renders_subcommands_recursively(self):
95
+ @click.group()
96
+ def cli():
97
+ pass
98
+
99
+ @cli.group()
100
+ def sub():
101
+ """A subcommand"""
102
+
103
+ @sub.command()
104
+ def nested():
105
+ """A nested command"""
106
+
107
+ output = generate(cli)
108
+
109
+ assert "cmd sub" in output
110
+ assert "cmd nested" in output
111
+
112
+ def test_renders_deeply_nested_command_structure(self):
113
+ @click.group()
114
+ def cli():
115
+ """My application"""
116
+
117
+ @cli.group()
118
+ def db():
119
+ """Database operations"""
120
+
121
+ @db.group()
122
+ def migrate():
123
+ """Run migrations"""
124
+
125
+ @migrate.command()
126
+ def up():
127
+ """Apply migrations"""
128
+
129
+ @migrate.command()
130
+ def down():
131
+ """Rollback migrations"""
132
+
133
+ output = generate(cli)
134
+
135
+ assert "cmd db" in output
136
+ assert "cmd migrate" in output
137
+ assert "cmd up" in output
138
+ assert "cmd down" in output
139
+
140
+
141
+ class TestOptions:
142
+ def test_renders_short_and_long_flags(self):
143
+ @click.command()
144
+ @click.option("-v", "--verbose", is_flag=True, help="Be verbose")
145
+ @click.option("-o", "--output", type=str, help="Output dir")
146
+ def cli(verbose, output):
147
+ pass
148
+
149
+ output = generate(cli)
150
+
151
+ assert 'flag "-v --verbose"' in output
152
+ assert 'flag "-o --output"' in output
153
+
154
+ def test_marks_required_options(self):
155
+ @click.command()
156
+ @click.option("--env", required=True, help="Target environment")
157
+ def cli(env):
158
+ pass
159
+
160
+ output = generate(cli)
161
+
162
+ assert "required=#true" in output
163
+
164
+ def test_renders_default_values(self):
165
+ @click.command()
166
+ @click.option("--format", default="json", help="Output format")
167
+ def cli(format):
168
+ pass
169
+
170
+ spec = convert_root(cli)
171
+ format_flag = next(f for f in spec.flags if f.long == "format")
172
+ assert format_flag.default == ["json"]
173
+
174
+ def test_preserves_string_zero_as_default(self):
175
+ @click.command()
176
+ @click.option("--port", default="0", type=str, help="Port number")
177
+ def cli(port):
178
+ pass
179
+
180
+ output = generate(cli)
181
+ assert "default=0" in output
182
+
183
+ def test_renders_boolean_default_true(self):
184
+ @click.command()
185
+ @click.option("--color", is_flag=True, default=True, help="Enable color")
186
+ def cli(color):
187
+ pass
188
+
189
+ spec = convert_root(cli)
190
+ color_flag = next(f for f in spec.flags if f.long == "color")
191
+ assert color_flag.default_bool is True
192
+
193
+ def test_skips_false_default_for_boolean_flags(self):
194
+ @click.command()
195
+ @click.option("--force", is_flag=True, default=False, help="Force the operation")
196
+ def cli(force):
197
+ pass
198
+
199
+ spec = convert_root(cli)
200
+ force_flag = next(f for f in spec.flags if f.long == "force")
201
+ assert force_flag.default == []
202
+ assert force_flag.default_bool is None
203
+
204
+ def test_renders_choices(self):
205
+ @click.command()
206
+ @click.option("--format", type=click.Choice(["json", "yaml", "toml"]), help="Output format")
207
+ def cli(format):
208
+ pass
209
+
210
+ spec = convert_root(cli)
211
+ format_flag = next(f for f in spec.flags if f.long == "format")
212
+ assert format_flag.arg is not None
213
+ assert format_flag.arg.choices is not None
214
+ assert format_flag.arg.choices.values == ["json", "yaml", "toml"]
215
+
216
+ def test_hides_hidden_options(self):
217
+ @click.command()
218
+ @click.option("--secret", type=str, help="Secret option", hidden=True)
219
+ def cli(secret):
220
+ pass
221
+
222
+ spec = convert_root(cli)
223
+ secret_flag = next(f for f in spec.flags if f.long == "secret")
224
+ assert secret_flag.hide is True
225
+
226
+ def test_handles_count_options(self):
227
+ @click.command()
228
+ @click.option("-v", "--verbose", count=True, help="Verbosity level")
229
+ def cli(verbose):
230
+ pass
231
+
232
+ spec = convert_root(cli)
233
+ verbose_flag = next(f for f in spec.flags if f.long == "verbose")
234
+ assert verbose_flag.count is True
235
+
236
+ def test_handles_multiple_options(self):
237
+ @click.command()
238
+ @click.option("--include", type=str, multiple=True, help="Include patterns")
239
+ def cli(include):
240
+ pass
241
+
242
+ spec = convert_root(cli)
243
+ include_flag = next(f for f in spec.flags if f.long == "include")
244
+ assert include_flag.var is True
245
+ assert include_flag.arg is not None
246
+ assert include_flag.arg.var is True
247
+
248
+ def test_renders_deprecated_options(self):
249
+ @click.command()
250
+ @click.option("--old-flag", type=str, help="Old flag", deprecated="Use --new-flag instead")
251
+ def cli(old_flag):
252
+ pass
253
+
254
+ spec = convert_root(cli)
255
+ old_flag = next(f for f in spec.flags if f.long == "old-flag")
256
+ assert old_flag.deprecated == "Use --new-flag instead"
257
+
258
+ def test_renders_long_only_flags(self):
259
+ @click.command()
260
+ @click.option("--verbose", is_flag=True, help="Be verbose")
261
+ def cli(verbose):
262
+ pass
263
+
264
+ output = generate(cli)
265
+ assert "flag --verbose" in output
266
+
267
+ def test_renders_short_only_flags(self):
268
+ @click.command()
269
+ @click.option("-v", is_flag=True, help="Be verbose")
270
+ def cli(verbose):
271
+ pass
272
+
273
+ output = generate(cli)
274
+ assert "flag -v" in output
275
+
276
+
277
+ class TestBooleanFlags:
278
+ def test_does_not_render_arg_for_boolean_flags(self):
279
+ @click.command()
280
+ @click.option("--force", is_flag=True, help="Force the operation")
281
+ def cli(force):
282
+ pass
283
+
284
+ spec = convert_root(cli)
285
+ force_flag = next(f for f in spec.flags if f.long == "force")
286
+ assert force_flag.arg is None
287
+
288
+
289
+ class TestNegatedOptions:
290
+ def test_renders_negate_for_toggle_flags(self):
291
+ @click.command()
292
+ @click.option("--color/--no-color", default=False, help="Color output")
293
+ def cli(color):
294
+ pass
295
+
296
+ spec = convert_root(cli)
297
+ color_flag = next(f for f in spec.flags if f.long == "color")
298
+ assert color_flag.negate == "--no-color"
299
+
300
+ def test_negated_boolean_options_do_not_have_arg(self):
301
+ @click.command()
302
+ @click.option("--color/--no-color", default=False, help="Color output")
303
+ def cli(color):
304
+ pass
305
+
306
+ spec = convert_root(cli)
307
+ color_flag = next(f for f in spec.flags if f.long == "color")
308
+ assert color_flag.arg is None
309
+
310
+
311
+ class TestEnvironmentVariables:
312
+ def test_renders_env_for_options_with_envvar(self):
313
+ @click.command()
314
+ @click.option("--color", type=str, help="Color output", envvar="MYCLI_COLOR")
315
+ def cli(color):
316
+ pass
317
+
318
+ spec = convert_root(cli)
319
+ color_flag = next(f for f in spec.flags if f.long == "color")
320
+ assert color_flag.env == "MYCLI_COLOR"
321
+
322
+ def test_has_no_env_when_envvar_not_set(self):
323
+ @click.command()
324
+ @click.option("--color", type=str, help="Color output")
325
+ def cli(color):
326
+ pass
327
+
328
+ spec = convert_root(cli)
329
+ color_flag = next(f for f in spec.flags if f.long == "color")
330
+ assert color_flag.env == ""
331
+
332
+
333
+ class TestSkipsBuiltinFlags:
334
+ def test_does_not_render_help_flag(self):
335
+ @click.command()
336
+ @click.option("--custom", type=str, help="A custom flag")
337
+ def cli(custom):
338
+ pass
339
+
340
+ spec = convert_root(cli)
341
+ flag_names = [f.long for f in spec.flags]
342
+ assert "help" not in flag_names
343
+ assert "custom" in flag_names
344
+
345
+
346
+ class TestPositionalArguments:
347
+ def test_converts_required_arguments(self):
348
+ @click.command()
349
+ @click.argument("file")
350
+ def cli(file):
351
+ pass
352
+
353
+ output = generate(cli)
354
+ assert "arg <file>" in output
355
+
356
+ def test_converts_optional_arguments(self):
357
+ @click.command()
358
+ @click.argument("name", required=False)
359
+ def cli(name):
360
+ pass
361
+
362
+ output = generate(cli)
363
+ assert "arg [name]" in output
364
+
365
+ def test_converts_variadic_arguments(self):
366
+ @click.command()
367
+ @click.argument("files", nargs=-1)
368
+ def cli(files):
369
+ pass
370
+
371
+ spec = convert_root(cli)
372
+ files_arg = next(a for a in spec.args if a.name == "files")
373
+ assert files_arg.var is True
374
+
375
+ def test_converts_mixed_required_and_optional_args(self):
376
+ @click.command()
377
+ @click.argument("source")
378
+ @click.argument("dest", required=False)
379
+ def cli(source, dest):
380
+ pass
381
+
382
+ spec = convert_root(cli)
383
+ assert spec.args[0].name == "source"
384
+ assert spec.args[0].required is True
385
+ assert spec.args[1].name == "dest"
386
+ assert spec.args[1].required is False
387
+
388
+ def test_converts_choices_on_arguments(self):
389
+ @click.command()
390
+ @click.argument("env", type=click.Choice(["dev", "staging", "prod"]))
391
+ def cli(env):
392
+ pass
393
+
394
+ spec = convert_root(cli)
395
+ env_arg = next(a for a in spec.args if a.name == "env")
396
+ assert env_arg.choices is not None
397
+ assert env_arg.choices.values == ["dev", "staging", "prod"]
398
+
399
+ def test_converts_default_on_arguments(self):
400
+ @click.command()
401
+ @click.argument("file", default="input.txt")
402
+ def cli(file):
403
+ pass
404
+
405
+ spec = convert_root(cli)
406
+ file_arg = next(a for a in spec.args if a.name == "file")
407
+ assert file_arg.default == ["input.txt"]
408
+
409
+
410
+ class TestCommands:
411
+ def test_renders_subcommands(self):
412
+ @click.group()
413
+ def cli():
414
+ pass
415
+
416
+ @cli.command()
417
+ def add():
418
+ """Add a file"""
419
+
420
+ @cli.command()
421
+ def remove():
422
+ """Remove files"""
423
+
424
+ output = generate(cli)
425
+
426
+ assert "cmd add" in output
427
+ assert "cmd remove" in output
428
+
429
+ def test_handles_hidden_commands(self):
430
+ @click.group()
431
+ def cli():
432
+ pass
433
+
434
+ @cli.command(hidden=True)
435
+ def secret():
436
+ """Secret command"""
437
+
438
+ spec = convert_root(cli)
439
+ assert len(spec.cmds) == 0
440
+
441
+ def test_renders_command_with_multiple_flags(self):
442
+ @click.group()
443
+ def cli():
444
+ pass
445
+
446
+ @cli.command()
447
+ @click.option("-o", "--output", type=str, help="Output directory")
448
+ @click.option("--minify", is_flag=True, help="Minify output")
449
+ @click.option("--watch", is_flag=True, help="Watch for changes")
450
+ def build(output, minify, watch):
451
+ """Build the project"""
452
+
453
+ spec = convert_root(cli)
454
+ build_cmd = next(c for c in spec.cmds if c.name == "build")
455
+ assert build_cmd is not None
456
+ assert len(build_cmd.flags) == 3
457
+ flag_longs = [f.long for f in build_cmd.flags]
458
+ assert "output" in flag_longs
459
+ assert "minify" in flag_longs
460
+ assert "watch" in flag_longs
461
+
462
+ def test_renders_command_aliases(self):
463
+ @click.group()
464
+ def cli():
465
+ pass
466
+
467
+ # Standard click doesn't natively support command aliases,
468
+ # but custom Group implementations may provide them via an `aliases` attribute.
469
+ # For the standard click case, commands simply have no aliases.
470
+ @cli.command()
471
+ def install():
472
+ """Install packages"""
473
+
474
+ spec = convert_root(cli)
475
+ install_cmd = next(c for c in spec.cmds if c.name == "install")
476
+ assert install_cmd.aliases == []
477
+
478
+ def test_renders_subcommand_with_summary_and_description(self):
479
+ @click.group()
480
+ def cli():
481
+ pass
482
+
483
+ @cli.command(short_help="Brief", help="Detailed explanation")
484
+ def sub():
485
+ pass
486
+
487
+ spec = convert_root(cli)
488
+ sub_cmd = next(c for c in spec.cmds if c.name == "sub")
489
+ assert sub_cmd.help == "Brief"
490
+ assert sub_cmd.help_long == "Detailed explanation"
491
+
492
+
493
+ class TestSubcommandRequired:
494
+ def test_sets_subcommand_required_for_non_runnable_groups(self):
495
+ @click.group()
496
+ def cli():
497
+ pass
498
+
499
+ @cli.group()
500
+ def config():
501
+ """Manage config"""
502
+
503
+ @config.command()
504
+ def get():
505
+ """Get a value"""
506
+
507
+ @config.command()
508
+ def set_():
509
+ """Set a value"""
510
+
511
+ output = generate(cli)
512
+
513
+ # config subcommand has subcommands but no positional args
514
+ assert "subcommand_required=#true" in output
515
+
516
+ def test_does_not_set_subcommand_required_for_leaf_commands(self):
517
+ @click.group()
518
+ def cli():
519
+ pass
520
+
521
+ @cli.command()
522
+ def start():
523
+ """Start"""
524
+
525
+ spec = convert_root(cli)
526
+ start_cmd = next(c for c in spec.cmds if c.name == "start")
527
+ assert not start_cmd.cmds # leaf has no subcommands
528
+ assert start_cmd.subcommand_required is False
529
+
530
+
531
+ class TestLongHelp:
532
+ def test_renders_long_about_for_command_with_short_and_long_help(self):
533
+ @click.command(short_help="Short help", help="This is a much longer description of the app.")
534
+ def cli():
535
+ pass
536
+
537
+ output = generate(cli)
538
+ assert 'about "Short help"' in output
539
+ assert "long_about" in output
540
+
541
+ def test_uses_description_as_about_when_no_short_help(self):
542
+ @click.command(help="Just a description")
543
+ def cli():
544
+ pass
545
+
546
+ spec = convert_root(cli)
547
+ assert spec.about == "Just a description"
548
+ assert spec.long == ""
549
+
550
+
551
+ class TestSpecialCharacterEscaping:
552
+ def test_handles_special_chars_in_flag_help(self):
553
+ @click.command()
554
+ @click.option("--format", type=str, help="Output format:\n json\n yaml")
555
+ def cli(format):
556
+ pass
557
+
558
+ output = generate(cli)
559
+ assert "help=" in output
560
+ kdl_content = output.split("\n", 1)[1] if output.startswith("//") else output
561
+ validate_kdl(kdl_content)
562
+
563
+ def test_handles_newlines_in_command_help(self):
564
+ @click.command(short_help="Short", help="First line.\nSecond line.\n\nThird paragraph.")
565
+ def cli():
566
+ pass
567
+
568
+ output = generate(cli)
569
+ assert "long_about" in output
570
+ kdl_content = output.split("\n", 1)[1] if output.startswith("//") else output
571
+ validate_kdl(kdl_content)
572
+
573
+
574
+ class TestKDLRoundTrip:
575
+ def test_generates_valid_kdl_for_basic_command(self):
576
+ @click.command()
577
+ @click.option("-v", "--verbose", is_flag=True, help="Be verbose")
578
+ @click.option("-f", "--file", type=str, help="Input file")
579
+ def cli(verbose, file):
580
+ pass
581
+
582
+ output = generate(cli)
583
+ kdl_content = output.split("\n", 1)[1] if output.startswith("//") else output
584
+ validate_kdl(kdl_content)
585
+
586
+ def test_generates_valid_kdl_for_nested_commands(self):
587
+ @click.group()
588
+ def cli():
589
+ pass
590
+
591
+ @cli.group()
592
+ def config():
593
+ """Config"""
594
+
595
+ @config.command()
596
+ def get():
597
+ """Get value"""
598
+
599
+ output = generate(cli)
600
+ kdl_content = output.split("\n", 1)[1] if output.startswith("//") else output
601
+ validate_kdl(kdl_content)
602
+
603
+ def test_generates_valid_kdl_for_command_with_flags_and_args(self):
604
+ @click.command()
605
+ @click.option("-v", "--verbose", is_flag=True, help="Be verbose")
606
+ @click.argument("file")
607
+ def cli(verbose, file):
608
+ pass
609
+
610
+ output = generate(cli)
611
+ kdl_content = output.split("\n", 1)[1] if output.startswith("//") else output
612
+ validate_kdl(kdl_content)
613
+
614
+
615
+ class TestGenerateKDL:
616
+ def test_is_alias_for_generate(self):
617
+ @click.command()
618
+ def cli():
619
+ """A CLI"""
620
+
621
+ assert generate_kdl(cli) == generate(cli)
622
+
623
+
624
+ class TestConvertRoot:
625
+ def test_returns_spec_with_correct_structure(self):
626
+ @click.command()
627
+ @click.version_option("2.0.0")
628
+ @click.option("--verbose", is_flag=True, help="Be verbose")
629
+ def cli(verbose):
630
+ """A CLI tool"""
631
+
632
+ spec = convert_root(cli, "mycli")
633
+
634
+ assert spec.name == "mycli"
635
+ assert spec.bin == "mycli"
636
+ assert spec.version == "2.0.0"
637
+ assert spec.about == "A CLI tool"
638
+ assert len(spec.flags) == 1
639
+ assert spec.flags[0].long == "verbose"
640
+
641
+ def test_includes_arguments_in_spec(self):
642
+ @click.command()
643
+ @click.argument("file")
644
+ @click.argument("output", required=False)
645
+ def cli(file, output):
646
+ pass
647
+
648
+ spec = convert_root(cli)
649
+
650
+ assert len(spec.args) == 2
651
+ assert spec.args[0].name == "file"
652
+ assert spec.args[0].required is True
653
+ assert spec.args[1].name == "output"
654
+ assert spec.args[1].required is False
655
+
656
+
657
+ class TestJSONOutput:
658
+ def test_generates_json_output_with_correct_structure(self):
659
+ @click.group()
660
+ @click.version_option("2.0.0")
661
+ @click.option("--verbose", is_flag=True, help="Be verbose")
662
+ def cli(verbose):
663
+ """A CLI tool"""
664
+
665
+ @cli.command()
666
+ def run():
667
+ """Run something"""
668
+
669
+ j = generate_json(cli, "mycli")
670
+ parsed = json.loads(j)
671
+
672
+ assert parsed["name"] == "mycli"
673
+ assert parsed["version"] == "2.0.0"
674
+ assert parsed["about"] == "A CLI tool"
675
+ assert len(parsed["flags"]) == 1
676
+ assert len(parsed["cmds"]) == 1
677
+ assert parsed["cmds"][0]["name"] == "run"
678
+
679
+ def test_includes_choices_in_json_output(self):
680
+ @click.command()
681
+ @click.option("--env", type=click.Choice(["dev", "prod"]), help="Environment")
682
+ def cli(env):
683
+ pass
684
+
685
+ j = generate_json(cli)
686
+ parsed = json.loads(j)
687
+
688
+ assert parsed["flags"][0]["arg"]["choices"] == ["dev", "prod"]
689
+
690
+ def test_includes_flag_details_in_json(self):
691
+ @click.command()
692
+ @click.option("--env", required=True, help="Target environment")
693
+ @click.option("--color/--no-color", default=False, help="Color output")
694
+ def cli(env, color):
695
+ pass
696
+
697
+ j = generate_json(cli)
698
+ parsed = json.loads(j)
699
+
700
+ assert len(parsed["flags"]) == 2
701
+ env_flag = next(f for f in parsed["flags"] if f["name"] == "--env")
702
+ assert env_flag["required"] is True
703
+ assert env_flag["arg"] is not None
704
+
705
+ color_flag = next(f for f in parsed["flags"] if f["name"] == "--color")
706
+ assert color_flag["negate"] == "--no-color"
707
+
708
+ def test_includes_args_in_json_output(self):
709
+ @click.command()
710
+ @click.argument("file")
711
+ @click.argument("output", required=False)
712
+ def cli(file, output):
713
+ pass
714
+
715
+ j = generate_json(cli)
716
+ parsed = json.loads(j)
717
+
718
+ assert len(parsed["args"]) == 2
719
+ assert parsed["args"][0]["name"] == "file"
720
+ # required=true is default, omitted from JSON
721
+ assert "required" not in parsed["args"][0]
722
+ assert parsed["args"][1]["name"] == "output"
723
+ assert parsed["args"][1]["required"] is False
724
+
725
+ def test_handles_custom_bin_name_in_json(self):
726
+ @click.command()
727
+ @click.option("--verbose", is_flag=True, help="Be verbose")
728
+ def cli(verbose):
729
+ """A tool"""
730
+
731
+ j = generate_json(cli, "my-tool")
732
+ parsed = json.loads(j)
733
+
734
+ assert parsed["bin"] == "my-tool"
735
+
736
+
737
+ class TestEdgeCases:
738
+ def test_handles_empty_command_with_no_options_or_args(self):
739
+ @click.command()
740
+ def cli():
741
+ pass
742
+
743
+ output = generate(cli)
744
+ assert "flag" not in output
745
+ assert "arg " not in output
746
+ assert "cmd" not in output
747
+
748
+ def test_handles_command_with_only_subcommands(self):
749
+ @click.group()
750
+ def cli():
751
+ pass
752
+
753
+ @cli.command()
754
+ def start():
755
+ """Start"""
756
+
757
+ @cli.command()
758
+ def stop():
759
+ """Stop"""
760
+
761
+ output = generate(cli)
762
+
763
+ assert "cmd start" in output
764
+ assert "cmd stop" in output
765
+
766
+ def test_handles_deeply_nested_command_structure(self):
767
+ @click.group()
768
+ def cli():
769
+ """My application"""
770
+
771
+ @cli.group()
772
+ def db():
773
+ """Database operations"""
774
+
775
+ @db.group()
776
+ def migrate():
777
+ """Run migrations"""
778
+
779
+ @migrate.command()
780
+ def up():
781
+ """Apply migrations"""
782
+
783
+ @migrate.command()
784
+ def down():
785
+ """Rollback migrations"""
786
+
787
+ output = generate(cli)
788
+
789
+ assert "cmd db" in output
790
+ assert 'help="Database operations"' in output
791
+ assert "cmd migrate" in output
792
+ assert 'help="Run migrations"' in output
793
+ assert "cmd up" in output
794
+ assert "cmd down" in output
795
+
796
+
797
+ class TestFullOutputFormat:
798
+ def test_matches_expected_kdl_output(self):
799
+ @click.command()
800
+ @click.version_option("1.0.0")
801
+ @click.option("-f", "--file", type=str, help="Some input file")
802
+ @click.option("--verbose", is_flag=True, help="Enable verbose output")
803
+ def cli(file, verbose):
804
+ """An example CLI"""
805
+
806
+ output = generate(cli, "example")
807
+
808
+ assert "name example" in output
809
+ assert "bin example" in output
810
+ assert "version 1.0.0" in output
811
+ assert 'about "An example CLI"' in output
812
+ assert 'flag "-f --file"' in output
813
+ assert "flag --verbose" in output