usage-spec 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,68 @@
1
+ Metadata-Version: 2.4
2
+ Name: usage-spec
3
+ Version: 1.0.0
4
+ Summary: Core types and rendering for usage spec
5
+ License-Expression: MIT
6
+ Keywords: cli,kdl,spec,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
+ Provides-Extra: dev
17
+ Requires-Dist: pytest>=8.0; extra == 'dev'
18
+ Description-Content-Type: text/markdown
19
+
20
+ # usage-spec
21
+
22
+ Core types and rendering for [usage spec](https://usage.jdx.dev).
23
+
24
+ ## Install
25
+
26
+ ```sh
27
+ pip install usage-spec
28
+ ```
29
+
30
+ ## API
31
+
32
+ ### Types
33
+
34
+ ```python
35
+ @dataclass
36
+ class Spec:
37
+ name: str
38
+ bin: str
39
+ version: str
40
+ about: str
41
+ long: str
42
+ usage: str
43
+ flags: list[SpecFlag]
44
+ args: list[SpecArg]
45
+ cmds: list[SpecCommand]
46
+ ```
47
+
48
+ ### Functions
49
+
50
+ ```python
51
+ from usage_spec import generate, render_kdl, render_json, validate_kdl
52
+
53
+ # Render Spec as KDL string
54
+ render_kdl(spec: Spec) -> str
55
+
56
+ # Render Spec as JSON string
57
+ render_json(spec: Spec) -> str
58
+
59
+ # Generate spec output with optional format and comment
60
+ generate(spec: Spec, format: str = "kdl", comment: str | None = None) -> str
61
+
62
+ # Validate KDL string by basic structural check
63
+ validate_kdl(kdl: str) -> None
64
+ ```
65
+
66
+ ## License
67
+
68
+ MIT
@@ -0,0 +1,49 @@
1
+ # usage-spec
2
+
3
+ Core types and rendering for [usage spec](https://usage.jdx.dev).
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ pip install usage-spec
9
+ ```
10
+
11
+ ## API
12
+
13
+ ### Types
14
+
15
+ ```python
16
+ @dataclass
17
+ class Spec:
18
+ name: str
19
+ bin: str
20
+ version: str
21
+ about: str
22
+ long: str
23
+ usage: str
24
+ flags: list[SpecFlag]
25
+ args: list[SpecArg]
26
+ cmds: list[SpecCommand]
27
+ ```
28
+
29
+ ### Functions
30
+
31
+ ```python
32
+ from usage_spec import generate, render_kdl, render_json, validate_kdl
33
+
34
+ # Render Spec as KDL string
35
+ render_kdl(spec: Spec) -> str
36
+
37
+ # Render Spec as JSON string
38
+ render_json(spec: Spec) -> str
39
+
40
+ # Generate spec output with optional format and comment
41
+ generate(spec: Spec, format: str = "kdl", comment: str | None = None) -> str
42
+
43
+ # Validate KDL string by basic structural check
44
+ validate_kdl(kdl: str) -> None
45
+ ```
46
+
47
+ ## License
48
+
49
+ MIT
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "usage-spec"
7
+ version = "1.0.0"
8
+ description = "Core types and rendering for usage spec"
9
+ requires-python = ">=3.10"
10
+ license = "MIT"
11
+ readme = "README.md"
12
+ keywords = ["usage", "cli", "kdl", "spec"]
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
+
24
+ [project.optional-dependencies]
25
+ dev = ["pytest>=8.0"]
26
+
27
+ [tool.hatch.build.targets.wheel]
28
+ packages = ["src/usage_spec"]
29
+
30
+ [tool.hatch.build.targets.wheel.sources]
31
+ "src" = ""
32
+
33
+ [tool.pytest.ini_options]
34
+ testpaths = ["tests"]
@@ -0,0 +1,32 @@
1
+ """Usage spec core - Python implementation aligned with @usage-spec/core."""
2
+
3
+ from .spec import Spec, SpecArg, SpecFlag, SpecCommand, SpecChoices
4
+ from .kdl import render_kdl, validate_kdl
5
+ from .json import render_json
6
+
7
+ __all__ = [
8
+ "Spec",
9
+ "SpecArg",
10
+ "SpecFlag",
11
+ "SpecCommand",
12
+ "SpecChoices",
13
+ "render_kdl",
14
+ "validate_kdl",
15
+ "render_json",
16
+ "generate",
17
+ ]
18
+
19
+
20
+ def generate(
21
+ spec: Spec,
22
+ *,
23
+ format: str = "kdl",
24
+ comment: str | None = None,
25
+ ) -> str:
26
+ """Generate usage spec output in the specified format."""
27
+ output = render_json(spec) if format == "json" else render_kdl(spec)
28
+
29
+ if comment:
30
+ return f"// {comment}\n{output}"
31
+
32
+ return output
@@ -0,0 +1,123 @@
1
+ """JSON renderer for usage spec, aligned with @usage-spec/core json.ts output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ from .spec import Spec, SpecArg, SpecFlag, SpecCommand
9
+
10
+
11
+ def _arg_to_json(arg: SpecArg) -> dict[str, Any]:
12
+ result: dict[str, Any] = {"name": arg.name}
13
+
14
+ if arg.help:
15
+ result["help"] = arg.help
16
+ if not arg.required:
17
+ result["required"] = False
18
+ if arg.var:
19
+ result["var"] = True
20
+ if arg.hide:
21
+ result["hide"] = True
22
+ if len(arg.default) == 1:
23
+ result["default"] = arg.default[0]
24
+ if len(arg.default) > 1:
25
+ result["default"] = arg.default
26
+ if arg.choices:
27
+ result["choices"] = arg.choices.values
28
+
29
+ return result
30
+
31
+
32
+ def _flag_to_json(flag: SpecFlag) -> dict[str, Any]:
33
+ result: dict[str, Any] = {}
34
+
35
+ name_parts: list[str] = []
36
+ if flag.short:
37
+ name_parts.append(f"-{flag.short}")
38
+ if flag.long:
39
+ name_parts.append(f"--{flag.long}")
40
+ result["name"] = " ".join(name_parts)
41
+
42
+ if flag.help:
43
+ result["help"] = flag.help
44
+ if flag.help_long:
45
+ result["help_long"] = flag.help_long
46
+ if flag.required:
47
+ result["required"] = True
48
+ if flag.hide:
49
+ result["hide"] = True
50
+ if flag.global_:
51
+ result["global"] = True
52
+ if flag.count:
53
+ result["count"] = True
54
+ if flag.var:
55
+ result["var"] = True
56
+ if flag.negate:
57
+ result["negate"] = flag.negate
58
+ if flag.deprecated:
59
+ result["deprecated"] = flag.deprecated
60
+ if flag.env:
61
+ result["env"] = flag.env
62
+ if len(flag.default) == 1:
63
+ result["default"] = flag.default[0]
64
+ if len(flag.default) > 1:
65
+ result["default"] = flag.default
66
+
67
+ if flag.arg:
68
+ result["arg"] = _arg_to_json(flag.arg)
69
+
70
+ return result
71
+
72
+
73
+ def _cmd_to_json(cmd: SpecCommand) -> dict[str, Any]:
74
+ result: dict[str, Any] = {"name": cmd.name}
75
+
76
+ if cmd.help:
77
+ result["help"] = cmd.help
78
+ if cmd.help_long:
79
+ result["help_long"] = cmd.help_long
80
+ if cmd.hide:
81
+ result["hide"] = True
82
+ if cmd.deprecated:
83
+ result["deprecated"] = cmd.deprecated
84
+ if cmd.aliases:
85
+ result["aliases"] = cmd.aliases
86
+ if cmd.subcommand_required:
87
+ result["subcommand_required"] = True
88
+
89
+ if cmd.flags:
90
+ result["flags"] = [_flag_to_json(f) for f in cmd.flags]
91
+ if cmd.args:
92
+ result["args"] = [_arg_to_json(a) for a in cmd.args]
93
+ if cmd.cmds:
94
+ result["cmds"] = [_cmd_to_json(c) for c in cmd.cmds]
95
+
96
+ return result
97
+
98
+
99
+ def render_json(spec: Spec) -> str:
100
+ """Render a Spec to JSON format string."""
101
+ result: dict[str, Any] = {}
102
+
103
+ if spec.name:
104
+ result["name"] = spec.name
105
+ if spec.bin:
106
+ result["bin"] = spec.bin
107
+ if spec.version:
108
+ result["version"] = spec.version
109
+ if spec.about:
110
+ result["about"] = spec.about
111
+ if spec.long:
112
+ result["long_about"] = spec.long
113
+ if spec.usage:
114
+ result["usage"] = spec.usage
115
+
116
+ if spec.flags:
117
+ result["flags"] = [_flag_to_json(f) for f in spec.flags]
118
+ if spec.args:
119
+ result["args"] = [_arg_to_json(a) for a in spec.args]
120
+ if spec.cmds:
121
+ result["cmds"] = [_cmd_to_json(c) for c in spec.cmds]
122
+
123
+ return json.dumps(result, indent=2) + "\n"
@@ -0,0 +1,247 @@
1
+ """KDL renderer for usage spec, aligned with @usage-spec/core kdl.ts output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .spec import Spec, SpecArg, SpecFlag, SpecCommand, SpecChoices
6
+
7
+
8
+ def _escape_kdl_string(value: str) -> str:
9
+ """Escape a string for KDL format."""
10
+ if not value:
11
+ return '""'
12
+ # KDL strings: if the value contains special chars, wrap in quotes
13
+ needs_quoting = any(c in value for c in (' ', '"', '\n', '\r', '\t', '\\', '{', '}', '#', '\0'))
14
+ if not needs_quoting and value not in ('true', 'false', 'null'):
15
+ return value
16
+ escaped = value.replace('\\', '\\\\').replace('"', '\\"').replace('\n', '\\n').replace('\r', '\\r').replace('\t', '\\t')
17
+ return f'"{escaped}"'
18
+
19
+
20
+ def _format_bool(value: bool) -> str:
21
+ return "#true" if value else "#false"
22
+
23
+
24
+ def _indent(lines: list[str], level: int) -> list[str]:
25
+ prefix = " " * level
26
+ return [prefix + line for line in lines]
27
+
28
+
29
+ def _render_choices(choices: SpecChoices) -> list[str]:
30
+ parts = " ".join(_escape_kdl_string(c) for c in choices.values)
31
+ return [f"choices {parts}"]
32
+
33
+
34
+ def _render_arg(arg: SpecArg, *, is_flag_arg: bool = False, indent_level: int = 0) -> list[str]:
35
+ lines: list[str] = []
36
+
37
+ if is_flag_arg:
38
+ usage = f"<{arg.name}>"
39
+ line_parts = [f"arg {_escape_kdl_string(usage)}"]
40
+ else:
41
+ # Build usage string: <required> or [optional], with … for variadic
42
+ if arg.required:
43
+ usage = f"<{arg.name}>"
44
+ else:
45
+ usage = f"[{arg.name}]"
46
+ if arg.var:
47
+ usage += "\u2026"
48
+
49
+ line_parts = [f"arg {_escape_kdl_string(usage)}"]
50
+
51
+ if arg.help:
52
+ line_parts.append(f"help={_escape_kdl_string(arg.help)}")
53
+ if not arg.required:
54
+ line_parts.append(f"required={_format_bool(False)}")
55
+ if arg.var:
56
+ line_parts.append(f"var={_format_bool(True)}")
57
+ if arg.hide:
58
+ line_parts.append(f"hide={_format_bool(True)}")
59
+ if len(arg.default) == 1:
60
+ line_parts.append(f"default={_escape_kdl_string(arg.default[0])}")
61
+
62
+ # Check for children (choices, multiple defaults)
63
+ children: list[str] = []
64
+
65
+ if not is_flag_arg and len(arg.default) > 1:
66
+ parts = " ".join(_escape_kdl_string(d) for d in arg.default)
67
+ children.append(f"default {parts}")
68
+
69
+ if arg.choices:
70
+ children.extend(_render_choices(arg.choices))
71
+
72
+ if is_flag_arg:
73
+ # Flag arg: only has help and choices as children/properties
74
+ line_parts_flag = [f"arg {_escape_kdl_string(f'<{arg.name}>')}"]
75
+ if arg.help:
76
+ line_parts_flag.append(f"help={_escape_kdl_string(arg.help)}")
77
+
78
+ if arg.choices:
79
+ children_flag = _indent(_render_choices(arg.choices), 1)
80
+ lines.append(" ".join(line_parts_flag) + " {")
81
+ lines.extend(children_flag)
82
+ lines.append("}")
83
+ else:
84
+ lines.append(" ".join(line_parts_flag))
85
+ return lines
86
+
87
+ if children:
88
+ lines.append(" ".join(line_parts) + " {")
89
+ lines.extend(_indent(children, 1))
90
+ lines.append("}")
91
+ else:
92
+ lines.append(" ".join(line_parts))
93
+
94
+ return lines
95
+
96
+
97
+ def _render_flag(flag: SpecFlag) -> list[str]:
98
+ # Build the flag name: "-s --long"
99
+ name_parts: list[str] = []
100
+ if flag.short:
101
+ name_parts.append(f"-{flag.short}")
102
+ if flag.long:
103
+ name_parts.append(f"--{flag.long}")
104
+ flag_name = " ".join(name_parts)
105
+
106
+ line_parts = [f"flag {_escape_kdl_string(flag_name)}"]
107
+
108
+ if flag.help:
109
+ line_parts.append(f"help={_escape_kdl_string(flag.help)}")
110
+ if flag.required:
111
+ line_parts.append(f"required={_format_bool(True)}")
112
+ if flag.var:
113
+ line_parts.append(f"var={_format_bool(True)}")
114
+ if flag.hide:
115
+ line_parts.append(f"hide={_format_bool(True)}")
116
+ if flag.global_:
117
+ line_parts.append(f"global={_format_bool(True)}")
118
+ if flag.count:
119
+ line_parts.append(f"count={_format_bool(True)}")
120
+ if flag.negate:
121
+ line_parts.append(f"negate={flag.negate}")
122
+ if flag.deprecated:
123
+ line_parts.append(f"deprecated={_escape_kdl_string(flag.deprecated)}")
124
+ if len(flag.default) == 1:
125
+ line_parts.append(f"default={_escape_kdl_string(flag.default[0])}")
126
+ elif flag.default_bool is not None:
127
+ line_parts.append(f"default={_format_bool(flag.default_bool)}")
128
+ if flag.env:
129
+ line_parts.append(f"env={_escape_kdl_string(flag.env)}")
130
+
131
+ # Check for children
132
+ children: list[str] = []
133
+
134
+ if flag.help_long:
135
+ children.append(f"long_help {_escape_kdl_string(flag.help_long)}")
136
+
137
+ if len(flag.default) > 1:
138
+ parts = " ".join(_escape_kdl_string(d) for d in flag.default)
139
+ children.append(f"default {parts}")
140
+
141
+ if flag.arg:
142
+ children.extend(_render_flag_arg(flag.arg))
143
+
144
+ if children:
145
+ return [" ".join(line_parts) + " {", *_indent(children, 1), "}"]
146
+ else:
147
+ return [" ".join(line_parts)]
148
+
149
+
150
+ def _render_flag_arg(arg: SpecArg) -> list[str]:
151
+ """Render a flag's inner arg node."""
152
+ line_parts = [f"arg {_escape_kdl_string(f'<{arg.name}>')}"]
153
+
154
+ if arg.help:
155
+ line_parts.append(f"help={_escape_kdl_string(arg.help)}")
156
+
157
+ if arg.choices:
158
+ children = _indent(_render_choices(arg.choices), 1)
159
+ return [" ".join(line_parts) + " {", *children, "}"]
160
+ else:
161
+ return [" ".join(line_parts)]
162
+
163
+
164
+ def _render_command(cmd: SpecCommand) -> list[str]:
165
+ line_parts = [f"cmd {_escape_kdl_string(cmd.name)}"]
166
+
167
+ if cmd.hide:
168
+ line_parts.append(f"hide={_format_bool(True)}")
169
+ if cmd.subcommand_required:
170
+ line_parts.append("subcommand_required=#true")
171
+ if cmd.help:
172
+ line_parts.append(f"help={_escape_kdl_string(cmd.help)}")
173
+ if cmd.deprecated:
174
+ line_parts.append(f"deprecated={_escape_kdl_string(cmd.deprecated)}")
175
+
176
+ # Check for children
177
+ children: list[str] = []
178
+
179
+ if cmd.aliases:
180
+ parts = " ".join(_escape_kdl_string(a) for a in cmd.aliases)
181
+ children.append(f"alias {parts}")
182
+
183
+ if cmd.help_long:
184
+ children.append(f"long_help {_escape_kdl_string(cmd.help_long)}")
185
+
186
+ for flag in cmd.flags:
187
+ children.extend(_render_flag(flag))
188
+
189
+ for arg in cmd.args:
190
+ children.extend(_render_arg(arg))
191
+
192
+ for sub in cmd.cmds:
193
+ children.extend(_render_command(sub))
194
+
195
+ if children:
196
+ indented = _indent(children, 1)
197
+ return [" ".join(line_parts) + " {", *indented, "}"]
198
+ else:
199
+ return [" ".join(line_parts)]
200
+
201
+
202
+ def render_kdl(spec: Spec) -> str:
203
+ """Render a Spec to KDL format string."""
204
+ lines: list[str] = []
205
+
206
+ if spec.name:
207
+ lines.append(f"name {_escape_kdl_string(spec.name)}")
208
+ if spec.bin:
209
+ lines.append(f"bin {_escape_kdl_string(spec.bin)}")
210
+ if spec.version:
211
+ lines.append(f"version {_escape_kdl_string(spec.version)}")
212
+ if spec.about:
213
+ lines.append(f"about {_escape_kdl_string(spec.about)}")
214
+ if spec.long:
215
+ lines.append(f"long_about {_escape_kdl_string(spec.long)}")
216
+ if spec.usage:
217
+ lines.append(f"usage {_escape_kdl_string(spec.usage)}")
218
+
219
+ for flag in spec.flags:
220
+ lines.extend(_render_flag(flag))
221
+
222
+ for arg in spec.args:
223
+ lines.extend(_render_arg(arg))
224
+
225
+ for cmd in spec.cmds:
226
+ lines.extend(_render_command(cmd))
227
+
228
+ return "\n".join(lines) + "\n"
229
+
230
+
231
+ def validate_kdl(kdl: str) -> None:
232
+ """Validate KDL output by attempting a basic structural parse.
233
+
234
+ This is a simplified validator - for full validation, use the
235
+ @bgotink/kdl parser via the TypeScript @usage-spec/core package.
236
+ """
237
+ if not kdl.strip():
238
+ return
239
+ # Basic bracket matching for child blocks
240
+ depth = 0
241
+ for line in kdl.split("\n"):
242
+ stripped = line.strip()
243
+ if not stripped or stripped.startswith("//"):
244
+ continue
245
+ depth += stripped.count("{") - stripped.count("}")
246
+ if depth != 0:
247
+ raise ValueError(f"Unbalanced braces in KDL output (depth={depth})")
@@ -0,0 +1,67 @@
1
+ """Usage spec type definitions, aligned with @usage-spec/core TypeScript types."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+
8
+ @dataclass
9
+ class SpecChoices:
10
+ values: list[str] = field(default_factory=list)
11
+
12
+
13
+ @dataclass
14
+ class SpecArg:
15
+ name: str = ""
16
+ help: str = ""
17
+ required: bool = True
18
+ var: bool = False
19
+ hide: bool = False
20
+ default: list[str] = field(default_factory=list)
21
+ choices: SpecChoices | None = None
22
+
23
+
24
+ @dataclass
25
+ class SpecFlag:
26
+ short: str = ""
27
+ long: str = ""
28
+ help: str = ""
29
+ help_long: str = ""
30
+ required: bool = False
31
+ hide: bool = False
32
+ global_: bool = False
33
+ count: bool = False
34
+ var: bool = False
35
+ negate: str = ""
36
+ deprecated: str = ""
37
+ default: list[str] = field(default_factory=list)
38
+ default_bool: bool | None = None
39
+ env: str = ""
40
+ arg: SpecArg | None = None
41
+
42
+
43
+ @dataclass
44
+ class SpecCommand:
45
+ name: str = ""
46
+ help: str = ""
47
+ help_long: str = ""
48
+ hide: bool = False
49
+ deprecated: str = ""
50
+ aliases: list[str] = field(default_factory=list)
51
+ subcommand_required: bool = False
52
+ flags: list[SpecFlag] = field(default_factory=list)
53
+ args: list[SpecArg] = field(default_factory=list)
54
+ cmds: list[SpecCommand] = field(default_factory=list)
55
+
56
+
57
+ @dataclass
58
+ class Spec:
59
+ name: str = ""
60
+ bin: str = ""
61
+ version: str = ""
62
+ about: str = ""
63
+ long: str = ""
64
+ usage: str = ""
65
+ flags: list[SpecFlag] = field(default_factory=list)
66
+ args: list[SpecArg] = field(default_factory=list)
67
+ cmds: list[SpecCommand] = field(default_factory=list)
@@ -0,0 +1,83 @@
1
+ """Tests for JSON renderer."""
2
+
3
+ import json
4
+
5
+ from usage_spec import Spec, SpecArg, SpecFlag, SpecCommand, SpecChoices, render_json
6
+
7
+
8
+ def test_simple_spec():
9
+ spec = Spec(
10
+ name="mycli",
11
+ bin="mycli",
12
+ version="2.0.0",
13
+ about="A CLI tool",
14
+ flags=[
15
+ SpecFlag(short="v", long="verbose", help="Be verbose"),
16
+ ],
17
+ cmds=[
18
+ SpecCommand(name="run", help="Run something"),
19
+ ],
20
+ )
21
+ result = json.loads(render_json(spec))
22
+ assert result["name"] == "mycli"
23
+ assert result["bin"] == "mycli"
24
+ assert result["version"] == "2.0.0"
25
+ assert result["about"] == "A CLI tool"
26
+ assert len(result["flags"]) == 1
27
+ assert len(result["cmds"]) == 1
28
+ assert result["cmds"][0]["name"] == "run"
29
+
30
+
31
+ def test_choices_in_json():
32
+ spec = Spec(
33
+ name="deploy",
34
+ flags=[
35
+ SpecFlag(
36
+ long="env",
37
+ help="Environment",
38
+ arg=SpecArg(name="ENV", choices=SpecChoices(values=["dev", "prod"])),
39
+ ),
40
+ ],
41
+ )
42
+ result = json.loads(render_json(spec))
43
+ assert result["flags"][0]["arg"]["choices"] == ["dev", "prod"]
44
+
45
+
46
+ def test_flag_details():
47
+ spec = Spec(
48
+ name="app",
49
+ flags=[
50
+ SpecFlag(long="env", help="Target environment", required=True, arg=SpecArg(name="ENV")),
51
+ SpecFlag(long="no-color", help="Disable color", negate="--color"),
52
+ ],
53
+ )
54
+ result = json.loads(render_json(spec))
55
+ assert len(result["flags"]) == 2
56
+ env_flag = next(f for f in result["flags"] if f["name"] == "--env")
57
+ assert env_flag["required"] is True
58
+ assert env_flag["arg"]["name"] == "ENV"
59
+
60
+ color_flag = next(f for f in result["flags"] if f["name"] == "--no-color")
61
+ assert color_flag["negate"] == "--color"
62
+
63
+
64
+ def test_args_in_json():
65
+ spec = Spec(
66
+ name="app",
67
+ args=[
68
+ SpecArg(name="file", help="Input file", required=True),
69
+ SpecArg(name="output", help="Output file", required=False),
70
+ ],
71
+ )
72
+ result = json.loads(render_json(spec))
73
+ assert len(result["args"]) == 2
74
+ assert result["args"][0]["name"] == "file"
75
+ # required=true is default, omitted in JSON
76
+ assert "required" not in result["args"][0]
77
+ assert result["args"][1]["required"] is False
78
+
79
+
80
+ def test_omits_empty_fields():
81
+ spec = Spec(name="empty")
82
+ result = json.loads(render_json(spec))
83
+ assert result == {"name": "empty"}
@@ -0,0 +1,261 @@
1
+ """Tests for KDL renderer."""
2
+
3
+ from usage_spec import Spec, SpecArg, SpecFlag, SpecCommand, SpecChoices, render_kdl, validate_kdl
4
+
5
+
6
+ def test_simple_spec():
7
+ spec = Spec(
8
+ name="mycli",
9
+ bin="mycli",
10
+ version="1.0.0",
11
+ about="A simple CLI",
12
+ flags=[
13
+ SpecFlag(short="v", long="verbose", help="Enable verbose output"),
14
+ SpecFlag(short="c", long="config", help="Config file path", arg=SpecArg(name="PATH")),
15
+ ],
16
+ )
17
+ output = render_kdl(spec)
18
+ assert "name mycli" in output
19
+ assert "bin mycli" in output
20
+ assert "version 1.0.0" in output
21
+ assert 'about "A simple CLI"' in output
22
+ assert 'flag "-v --verbose"' in output
23
+ assert 'help="Enable verbose output"' in output
24
+ assert 'flag "-c --config"' in output
25
+ assert 'help="Config file path"' in output
26
+
27
+
28
+ def test_required_option():
29
+ spec = Spec(
30
+ name="app",
31
+ flags=[
32
+ SpecFlag(long="env", help="Target environment", required=True, arg=SpecArg(name="ENV")),
33
+ ],
34
+ )
35
+ output = render_kdl(spec)
36
+ assert "required=#true" in output
37
+
38
+
39
+ def test_boolean_flag():
40
+ spec = Spec(
41
+ name="app",
42
+ flags=[
43
+ SpecFlag(long="force", help="Force the operation"),
44
+ ],
45
+ )
46
+ output = render_kdl(spec)
47
+ assert "flag --force" in output
48
+ # Boolean flags should not have arg
49
+ force_line = next(l for l in output.split("\n") if "flag --force" in l)
50
+ assert "arg" not in force_line
51
+
52
+
53
+ def test_default_values():
54
+ spec = Spec(
55
+ name="app",
56
+ flags=[
57
+ SpecFlag(long="output", help="Output format", default=["json"], arg=SpecArg(name="FORMAT")),
58
+ SpecFlag(long="retries", help="Number of retries", default=["3"], arg=SpecArg(name="N")),
59
+ ],
60
+ )
61
+ output = render_kdl(spec)
62
+ assert "default=json" in output
63
+ assert "default=3" in output
64
+
65
+
66
+ def test_boolean_default_true():
67
+ spec = Spec(
68
+ name="app",
69
+ flags=[
70
+ SpecFlag(long="color", help="Enable color", default_bool=True),
71
+ ],
72
+ )
73
+ output = render_kdl(spec)
74
+ assert "default=#true" in output
75
+
76
+
77
+ def test_boolean_default_false_omitted():
78
+ spec = Spec(
79
+ name="app",
80
+ flags=[
81
+ SpecFlag(long="force", help="Force operation", default_bool=None),
82
+ ],
83
+ )
84
+ output = render_kdl(spec)
85
+ force_line = next(l for l in output.split("\n") if "flag --force" in l)
86
+ assert "default" not in force_line
87
+
88
+
89
+ def test_negated_option():
90
+ spec = Spec(
91
+ name="app",
92
+ flags=[
93
+ SpecFlag(long="no-color", help="Disable color output", negate="--color"),
94
+ ],
95
+ )
96
+ output = render_kdl(spec)
97
+ assert "negate=--color" in output
98
+
99
+
100
+ def test_choices():
101
+ spec = Spec(
102
+ name="deploy",
103
+ flags=[
104
+ SpecFlag(
105
+ long="format",
106
+ help="Output format",
107
+ arg=SpecArg(name="TYPE", choices=SpecChoices(values=["json", "yaml", "toml"])),
108
+ ),
109
+ ],
110
+ )
111
+ output = render_kdl(spec)
112
+ assert "choices" in output
113
+ assert "json" in output
114
+ assert "yaml" in output
115
+ assert "toml" in output
116
+
117
+
118
+ def test_positional_args():
119
+ spec = Spec(
120
+ name="cmd",
121
+ args=[
122
+ SpecArg(name="file", help="Input file", required=True),
123
+ SpecArg(name="output", help="Output file", required=False),
124
+ ],
125
+ )
126
+ output = render_kdl(spec)
127
+ assert "arg <file>" in output
128
+ assert "arg [output]" in output
129
+ assert "required=#false" in output
130
+
131
+
132
+ def test_variadic_arg():
133
+ spec = Spec(
134
+ name="cmd",
135
+ args=[
136
+ SpecArg(name="files", help="Multiple files", required=True, var=True),
137
+ ],
138
+ )
139
+ output = render_kdl(spec)
140
+ assert "arg <files>\u2026" in output
141
+ assert "var=#true" in output
142
+
143
+
144
+ def test_subcommands():
145
+ spec = Spec(
146
+ name="app",
147
+ cmds=[
148
+ SpecCommand(name="start", help="Start the app"),
149
+ SpecCommand(name="stop", help="Stop the app"),
150
+ ],
151
+ )
152
+ output = render_kdl(spec)
153
+ assert "cmd start" in output
154
+ assert "cmd stop" in output
155
+
156
+
157
+ def test_nested_subcommands():
158
+ spec = Spec(
159
+ name="app",
160
+ cmds=[
161
+ SpecCommand(
162
+ name="sub",
163
+ help="A subcommand",
164
+ cmds=[
165
+ SpecCommand(name="nested", help="A nested command"),
166
+ ],
167
+ ),
168
+ ],
169
+ )
170
+ output = render_kdl(spec)
171
+ assert "cmd sub" in output
172
+ assert "cmd nested" in output
173
+
174
+
175
+ def test_subcommand_required():
176
+ spec = Spec(
177
+ name="app",
178
+ cmds=[
179
+ SpecCommand(
180
+ name="config",
181
+ help="Manage config",
182
+ subcommand_required=True,
183
+ cmds=[
184
+ SpecCommand(name="get", help="Get a value"),
185
+ SpecCommand(name="set", help="Set a value"),
186
+ ],
187
+ ),
188
+ ],
189
+ )
190
+ output = render_kdl(spec)
191
+ assert "subcommand_required=#true" in output
192
+
193
+
194
+ def test_aliases():
195
+ spec = Spec(
196
+ name="app",
197
+ cmds=[
198
+ SpecCommand(name="install", help="Install packages", aliases=["i", "add"]),
199
+ ],
200
+ )
201
+ output = render_kdl(spec)
202
+ assert "alias" in output
203
+ assert "i" in output
204
+ assert "add" in output
205
+
206
+
207
+ def test_long_help():
208
+ spec = Spec(
209
+ name="app",
210
+ about="Short help",
211
+ long="This is a much longer description of the app.",
212
+ )
213
+ output = render_kdl(spec)
214
+ assert 'about "Short help"' in output
215
+ assert 'long_about "This is a much longer description of the app."' in output
216
+
217
+
218
+ def test_env_variable():
219
+ spec = Spec(
220
+ name="app",
221
+ flags=[
222
+ SpecFlag(long="color", help="Color output", env="MYCLI_COLOR", arg=SpecArg(name="BOOL")),
223
+ ],
224
+ )
225
+ output = render_kdl(spec)
226
+ assert "env=MYCLI_COLOR" in output
227
+
228
+
229
+ def test_validate_kdl_valid():
230
+ spec = Spec(
231
+ name="app",
232
+ version="1.0.0",
233
+ about="A CLI",
234
+ flags=[
235
+ SpecFlag(short="v", long="verbose", help="Be verbose"),
236
+ SpecFlag(short="f", long="file", help="Input file", arg=SpecArg(name="FILE")),
237
+ ],
238
+ )
239
+ output = render_kdl(spec)
240
+ validate_kdl(output) # Should not raise
241
+
242
+
243
+ def test_special_char_escaping():
244
+ spec = Spec(
245
+ name="app",
246
+ about="Short help",
247
+ long="First line.\nSecond line.\n\nThird paragraph.",
248
+ )
249
+ output = render_kdl(spec)
250
+ assert "long_about" in output
251
+ validate_kdl(output)
252
+
253
+ def test_string_zero_default():
254
+ spec = Spec(
255
+ name="app",
256
+ flags=[
257
+ SpecFlag(long="port", help="Port number", default=["0"], arg=SpecArg(name="PORT")),
258
+ ],
259
+ )
260
+ output = render_kdl(spec)
261
+ assert "default=0" in output