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.
- usage_spec-1.0.0/.gitignore +8 -0
- usage_spec-1.0.0/PKG-INFO +68 -0
- usage_spec-1.0.0/README.md +49 -0
- usage_spec-1.0.0/pyproject.toml +34 -0
- usage_spec-1.0.0/src/usage_spec/__init__.py +32 -0
- usage_spec-1.0.0/src/usage_spec/json.py +123 -0
- usage_spec-1.0.0/src/usage_spec/kdl.py +247 -0
- usage_spec-1.0.0/src/usage_spec/spec.py +67 -0
- usage_spec-1.0.0/tests/test_json.py +83 -0
- usage_spec-1.0.0/tests/test_kdl.py +261 -0
|
@@ -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
|