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.
- usage_spec_click-1.0.0/.gitignore +8 -0
- usage_spec_click-1.0.0/PKG-INFO +93 -0
- usage_spec_click-1.0.0/README.md +71 -0
- usage_spec_click-1.0.0/pyproject.toml +35 -0
- usage_spec_click-1.0.0/src/click_usage/__init__.py +39 -0
- usage_spec_click-1.0.0/src/click_usage/convert.py +216 -0
- usage_spec_click-1.0.0/tests/test_click.py +813 -0
|
@@ -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
|