usage-spec-typer 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,99 @@
1
+ Metadata-Version: 2.4
2
+ Name: usage-spec-typer
3
+ Version: 1.0.0
4
+ Summary: Generates usage spec for CLIs written with Typer
5
+ License-Expression: MIT
6
+ Keywords: cli,kdl,shell-completion,typer,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: typer>=0.9
17
+ Requires-Dist: usage-spec
18
+ Requires-Dist: usage-spec-click
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest>=8.0; extra == 'dev'
21
+ Requires-Dist: typer>=0.9; extra == 'dev'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # usage-spec-typer
25
+
26
+ Generates [usage spec](https://usage.jdx.dev) for CLIs written with [Typer](https://typer.tiangolo.com/).
27
+
28
+ ## Install
29
+
30
+ ```sh
31
+ pip install usage-spec-typer
32
+ ```
33
+
34
+ ## Usage
35
+
36
+ ```python
37
+ import typer
38
+ from typer_usage import generate
39
+
40
+ app = typer.Typer(help="My CLI tool")
41
+
42
+ @app.command()
43
+ def hello(name: str = typer.Argument(help="Your name")):
44
+ """Say hello"""
45
+
46
+ @app.command()
47
+ def build(
48
+ output: str = typer.Option("dist", help="Output directory"),
49
+ verbose: bool = typer.Option(False, help="Enable verbose output"),
50
+ ):
51
+ """Build the project"""
52
+
53
+ print(generate(app, "mycli"))
54
+ ```
55
+
56
+ ## API
57
+
58
+ ### `generate(app, bin_name=None)`
59
+
60
+ Generates a usage spec in KDL format from a Typer `Typer` app.
61
+
62
+ ### `generate_kdl(app, bin_name=None)`
63
+
64
+ Alias for `generate()`.
65
+
66
+ ### `generate_json(app, bin_name=None)`
67
+
68
+ Generates a usage spec in JSON format.
69
+
70
+ ### `convert_root(app, bin_name=None)`
71
+
72
+ Converts a Typer `Typer` app to the `Spec` data structure.
73
+
74
+ ## Supported Features
75
+
76
+ | Typer Feature | Usage Spec Mapping |
77
+ |---|---|
78
+ | `app.info.name` / `bin_name` | `name` / `bin` |
79
+ | `app.info.help` | `about` |
80
+ | `typer.Option()` | `flag` |
81
+ | `typer.Argument()` | `arg` |
82
+ | `typer.Option(..., help=)` | Flag `help` |
83
+ | `typer.Argument(..., help=)` | Arg `help` |
84
+ | `typer.Option(...)` (ellipsis) | `flag required=#true` |
85
+ | `bool` type options | Boolean flag (no arg) |
86
+ | `--flag/--no-flag` | `negate` |
87
+ | `count=True` | `count=#true` |
88
+ | `type=click.Choice()` | `choices` |
89
+ | `default=...` | `default` |
90
+ | `hidden=True` | `hide=#true` |
91
+ | `envvar="..."` | `env` |
92
+ | `app.add_typer()` | `cmd` (recursive) |
93
+ | Subcommand groups | `subcommand_required=#true` |
94
+ | Single command app | Treated as root-level |
95
+ | `--install-completion` / `--show-completion` | Filtered out |
96
+
97
+ ## License
98
+
99
+ MIT
@@ -0,0 +1,76 @@
1
+ # usage-spec-typer
2
+
3
+ Generates [usage spec](https://usage.jdx.dev) for CLIs written with [Typer](https://typer.tiangolo.com/).
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ pip install usage-spec-typer
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```python
14
+ import typer
15
+ from typer_usage import generate
16
+
17
+ app = typer.Typer(help="My CLI tool")
18
+
19
+ @app.command()
20
+ def hello(name: str = typer.Argument(help="Your name")):
21
+ """Say hello"""
22
+
23
+ @app.command()
24
+ def build(
25
+ output: str = typer.Option("dist", help="Output directory"),
26
+ verbose: bool = typer.Option(False, help="Enable verbose output"),
27
+ ):
28
+ """Build the project"""
29
+
30
+ print(generate(app, "mycli"))
31
+ ```
32
+
33
+ ## API
34
+
35
+ ### `generate(app, bin_name=None)`
36
+
37
+ Generates a usage spec in KDL format from a Typer `Typer` app.
38
+
39
+ ### `generate_kdl(app, bin_name=None)`
40
+
41
+ Alias for `generate()`.
42
+
43
+ ### `generate_json(app, bin_name=None)`
44
+
45
+ Generates a usage spec in JSON format.
46
+
47
+ ### `convert_root(app, bin_name=None)`
48
+
49
+ Converts a Typer `Typer` app to the `Spec` data structure.
50
+
51
+ ## Supported Features
52
+
53
+ | Typer Feature | Usage Spec Mapping |
54
+ |---|---|
55
+ | `app.info.name` / `bin_name` | `name` / `bin` |
56
+ | `app.info.help` | `about` |
57
+ | `typer.Option()` | `flag` |
58
+ | `typer.Argument()` | `arg` |
59
+ | `typer.Option(..., help=)` | Flag `help` |
60
+ | `typer.Argument(..., help=)` | Arg `help` |
61
+ | `typer.Option(...)` (ellipsis) | `flag required=#true` |
62
+ | `bool` type options | Boolean flag (no arg) |
63
+ | `--flag/--no-flag` | `negate` |
64
+ | `count=True` | `count=#true` |
65
+ | `type=click.Choice()` | `choices` |
66
+ | `default=...` | `default` |
67
+ | `hidden=True` | `hide=#true` |
68
+ | `envvar="..."` | `env` |
69
+ | `app.add_typer()` | `cmd` (recursive) |
70
+ | Subcommand groups | `subcommand_required=#true` |
71
+ | Single command app | Treated as root-level |
72
+ | `--install-completion` / `--show-completion` | Filtered out |
73
+
74
+ ## License
75
+
76
+ MIT
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "usage-spec-typer"
7
+ version = "1.0.0"
8
+ description = "Generates usage spec for CLIs written with Typer"
9
+ requires-python = ">=3.10"
10
+ license = "MIT"
11
+ readme = "README.md"
12
+ keywords = ["typer", "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", "usage-spec-click", "typer>=0.9"]
24
+
25
+ [project.optional-dependencies]
26
+ dev = ["pytest>=8.0", "typer>=0.9"]
27
+
28
+ [tool.hatch.build.targets.wheel]
29
+ packages = ["src/typer_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 Typer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
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(app: typer.Typer, bin_name: str | None = None) -> str:
26
+ """Generate usage spec in KDL format from a Typer app."""
27
+ spec = convert_root(app, bin_name)
28
+ return core_generate(spec, format="kdl", comment="@generated by usage-spec-typer from Typer metadata")
29
+
30
+
31
+ def generate_kdl(app: typer.Typer, bin_name: str | None = None) -> str:
32
+ """Generate usage spec in KDL format (alias for generate)."""
33
+ return generate(app, bin_name)
34
+
35
+
36
+ def generate_json(app: typer.Typer, bin_name: str | None = None) -> str:
37
+ """Generate usage spec in JSON format from a Typer app."""
38
+ spec = convert_root(app, bin_name)
39
+ return core_generate(spec, format="json")
@@ -0,0 +1,139 @@
1
+ """Convert Typer apps to usage spec.
2
+
3
+ Typer is built on top of click, so this module reuses click conversion logic
4
+ and adds Typer-specific handling.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ import click as _click
12
+ import typer
13
+ from usage_spec import Spec, SpecArg, SpecFlag, SpecCommand, SpecChoices
14
+
15
+ from click_usage.convert import convert_root as _click_convert_root, _convert_arg, _convert_flag, _convert_command
16
+
17
+ # Typer auto-generated flags to skip
18
+ _TYPER_BUILTIN_FLAG_NAMES = frozenset({
19
+ "install-completion",
20
+ "show-completion",
21
+ })
22
+
23
+
24
+ def _is_typer_argument(arg: _click.Argument) -> bool:
25
+ return hasattr(arg, "help") # TyperArgument has help; standard click.Argument does not
26
+
27
+
28
+ def _convert_typer_arg(arg: _click.Argument) -> SpecArg:
29
+ """Convert a TyperArgument, which has help/hidden that standard click.Argument lacks."""
30
+ result = _convert_arg(arg)
31
+
32
+ if _is_typer_argument(arg):
33
+ result.help = getattr(arg, "help", "") or ""
34
+ result.hide = getattr(arg, "hidden", False)
35
+
36
+ return result
37
+
38
+
39
+ def _convert_typer_command(cmd: _click.BaseCommand) -> SpecCommand:
40
+ """Convert a click command that may contain Typer-specific params."""
41
+ sc = _convert_command(cmd)
42
+
43
+ # Replace args with Typer-aware conversion
44
+ sc.args = []
45
+ for param in cmd.params:
46
+ if isinstance(param, _click.Argument):
47
+ sc.args.append(_convert_typer_arg(param))
48
+
49
+ # Filter out Typer built-in flags
50
+ sc.flags = [f for f in sc.flags if f.long not in _TYPER_BUILTIN_FLAG_NAMES]
51
+
52
+ return sc
53
+
54
+
55
+ def _is_typer_builtin_option(opt: _click.Option) -> bool:
56
+ name = opt.name or ""
57
+ return name in _TYPER_BUILTIN_FLAG_NAMES or name in ("help", "version")
58
+
59
+
60
+ def convert_root(app: typer.Typer, bin_name: str | None = None) -> Spec:
61
+ """Convert a Typer app to a Spec object."""
62
+ name = bin_name or app.info.name or ""
63
+
64
+ spec = Spec(
65
+ name=name,
66
+ bin=name,
67
+ version="",
68
+ about=app.info.help or "",
69
+ long="",
70
+ usage="",
71
+ flags=[],
72
+ args=[],
73
+ cmds=[],
74
+ )
75
+
76
+ # Handle empty Typer apps (no commands registered)
77
+ if not app.registered_commands and not app.registered_groups:
78
+ return spec
79
+
80
+ # Get the underlying click command from Typer
81
+ try:
82
+ click_cmd = typer.main.get_command(app)
83
+ except RuntimeError:
84
+ return spec
85
+
86
+ if not name:
87
+ name = click_cmd.name or ""
88
+ spec.name = name
89
+ spec.bin = name
90
+
91
+ # If Typer has a single command, it returns a Command, not a Group.
92
+ # We treat that single command as the root.
93
+ is_group = isinstance(click_cmd, _click.Group)
94
+
95
+ if is_group:
96
+ # Multi-command app: root is the group, commands are subcommands
97
+ spec.about = app.info.help or click_cmd.short_help or click_cmd.help or ""
98
+ spec.long = (app.info.help and click_cmd.help and click_cmd.help) or ""
99
+
100
+ # Root-level params from the group
101
+ for param in click_cmd.params:
102
+ if isinstance(param, _click.Option):
103
+ if _is_typer_builtin_option(param):
104
+ if param.name == "version" and param.default is not None:
105
+ spec.version = str(param.default)
106
+ continue
107
+ spec.flags.append(_convert_flag(param))
108
+ elif isinstance(param, _click.Argument):
109
+ spec.args.append(_convert_typer_arg(param))
110
+
111
+ # Subcommands
112
+ group = click_cmd # type: _click.Group
113
+ if group.commands:
114
+ for sub_name, sub_cmd in group.commands.items():
115
+ if sub_cmd.hidden:
116
+ continue
117
+ spec.cmds.append(_convert_typer_command(sub_cmd))
118
+ else:
119
+ ctx = _click.Context(click_cmd)
120
+ for sub_name in group.list_commands(ctx):
121
+ sub_cmd = group.get_command(ctx, sub_name)
122
+ if sub_cmd and not sub_cmd.hidden:
123
+ spec.cmds.append(_convert_typer_command(sub_cmd))
124
+ else:
125
+ # Single-command app: treat the command as root
126
+ spec.about = app.info.help or click_cmd.short_help or click_cmd.help or ""
127
+
128
+ for param in click_cmd.params:
129
+ if isinstance(param, _click.Option):
130
+ if _is_typer_builtin_option(param):
131
+ continue
132
+ spec.flags.append(_convert_flag(param))
133
+ elif isinstance(param, _click.Argument):
134
+ spec.args.append(_convert_typer_arg(param))
135
+
136
+ # Filter out Typer built-in flags
137
+ spec.flags = [f for f in spec.flags if f.long not in _TYPER_BUILTIN_FLAG_NAMES]
138
+
139
+ return spec
@@ -0,0 +1,887 @@
1
+ """Tests for Typer usage spec integration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Optional
7
+
8
+ import click
9
+ import typer
10
+ from typer_usage import generate, generate_kdl, generate_json, convert_root
11
+ from usage_spec import validate_kdl
12
+
13
+
14
+ class TestSimpleCommand:
15
+ def test_generates_spec_for_basic_cli(self):
16
+ app = typer.Typer(help="A simple CLI")
17
+
18
+ @app.command()
19
+ def hello(name: str = typer.Argument(help="Your name")):
20
+ """Say hello"""
21
+
22
+ output = generate(app, "mycli")
23
+
24
+ assert "name mycli" in output
25
+ assert "bin mycli" in output
26
+ assert 'about "A simple CLI"' in output
27
+
28
+ def test_includes_generated_comment_header(self):
29
+ app = typer.Typer()
30
+
31
+ @app.command()
32
+ def hello():
33
+ pass
34
+
35
+ output = generate(app)
36
+ assert "// @generated by usage-spec-typer from Typer metadata" in output
37
+
38
+ def test_uses_custom_bin_name(self):
39
+ app = typer.Typer()
40
+
41
+ @app.command()
42
+ def hello():
43
+ pass
44
+
45
+ output = generate(app, "my-app")
46
+ assert "bin my-app" in output
47
+
48
+
49
+ class TestVersion:
50
+ def test_extracts_version_from_callback(self):
51
+ app = typer.Typer()
52
+
53
+ @app.callback()
54
+ def main(version: bool = typer.Option(False, "--version", is_flag=True)):
55
+ pass
56
+
57
+ @app.command()
58
+ def hello():
59
+ pass
60
+
61
+ # Typer doesn't natively extract version like click.version_option,
62
+ # version is just a flag
63
+ spec = convert_root(app)
64
+ version_flag = next((f for f in spec.flags if f.long == "version"), None)
65
+ # version flag is filtered as a builtin by click
66
+ assert version_flag is None
67
+
68
+ def test_skips_version_field_when_not_set(self):
69
+ app = typer.Typer()
70
+
71
+ @app.command()
72
+ def hello():
73
+ pass
74
+
75
+ output = generate(app)
76
+ assert "version " not in output
77
+
78
+
79
+ class TestAboutDescription:
80
+ def test_extracts_help_as_about(self):
81
+ app = typer.Typer(help="A simple CLI tool")
82
+
83
+ @app.command()
84
+ def hello():
85
+ pass
86
+
87
+ spec = convert_root(app)
88
+ assert spec.about == "A simple CLI tool"
89
+
90
+ def test_uses_app_help_as_about_when_no_command_help(self):
91
+ app = typer.Typer(help="App description")
92
+
93
+ @app.command()
94
+ def hello():
95
+ pass
96
+
97
+ spec = convert_root(app)
98
+ assert spec.about == "App description"
99
+
100
+ def test_sets_long_when_app_has_help_and_command_has_help(self):
101
+ app = typer.Typer(help="App help")
102
+
103
+ @app.command()
104
+ def hello():
105
+ """Command help"""
106
+
107
+ # Multi-command: app.help → about, command.help → cmd.help
108
+ spec = convert_root(app)
109
+ assert spec.about == "App help"
110
+
111
+
112
+ class TestNestedSubcommands:
113
+ def test_renders_subcommands_for_multi_command_app(self):
114
+ app = typer.Typer()
115
+
116
+ @app.command()
117
+ def hello():
118
+ """Say hello"""
119
+
120
+ @app.command()
121
+ def goodbye():
122
+ """Say goodbye"""
123
+
124
+ output = generate(app)
125
+
126
+ assert "cmd hello" in output
127
+ assert "cmd goodbye" in output
128
+
129
+ def test_renders_deeply_nested_command_structure(self):
130
+ app = typer.Typer()
131
+ db_app = typer.Typer()
132
+ app.add_typer(db_app, name="db")
133
+
134
+ @db_app.command()
135
+ def migrate():
136
+ """Run migrations"""
137
+
138
+ output = generate(app)
139
+
140
+ assert "cmd db" in output
141
+ assert "cmd migrate" in output
142
+
143
+ def test_renders_subgroup_with_commands(self):
144
+ app = typer.Typer()
145
+ config_app = typer.Typer(help="Manage configuration")
146
+ app.add_typer(config_app, name="config")
147
+
148
+ @config_app.command()
149
+ def get():
150
+ """Get a value"""
151
+
152
+ @config_app.command()
153
+ def set_():
154
+ """Set a value"""
155
+
156
+ spec = convert_root(app)
157
+ config_cmd = next((c for c in spec.cmds if c.name == "config"), None)
158
+ assert config_cmd is not None
159
+ assert len(config_cmd.cmds) == 2
160
+
161
+
162
+ class TestOptions:
163
+ def test_renders_options_on_subcommand(self):
164
+ app = typer.Typer()
165
+
166
+ @app.command()
167
+ def hello():
168
+ pass
169
+
170
+ @app.command()
171
+ def build(
172
+ output: str = typer.Option("dist", help="Output directory"),
173
+ ):
174
+ """Build the project"""
175
+
176
+ spec = convert_root(app)
177
+ build_cmd = next((c for c in spec.cmds if c.name == "build"), None)
178
+ assert build_cmd is not None
179
+ assert len(build_cmd.flags) >= 1
180
+
181
+ def test_marks_required_options(self):
182
+ app = typer.Typer()
183
+
184
+ @app.command()
185
+ def deploy(
186
+ env: str = typer.Option(..., help="Target environment"),
187
+ ):
188
+ pass
189
+
190
+ output = generate(app)
191
+
192
+ assert "required=#true" in output
193
+
194
+ def test_renders_default_values(self):
195
+ app = typer.Typer()
196
+
197
+ @app.command()
198
+ def build(
199
+ output: str = typer.Option("dist", help="Output directory"),
200
+ ):
201
+ pass
202
+
203
+ spec = convert_root(app)
204
+ # Single command becomes root, check root flags
205
+ output_flag = next((f for f in spec.flags if f.long == "output"), None)
206
+ if output_flag is None:
207
+ # Multi-command: check in build subcommand
208
+ build_cmd = next((c for c in spec.cmds if c.name == "build"), None)
209
+ assert build_cmd is not None
210
+ output_flag = next((f for f in build_cmd.flags if f.long == "output"), None)
211
+ assert output_flag is not None
212
+ assert output_flag.default == ["dist"]
213
+
214
+ def test_preserves_string_zero_as_default(self):
215
+ app = typer.Typer()
216
+
217
+ @app.command()
218
+ def run(
219
+ port: str = typer.Option("0", help="Port number"),
220
+ ):
221
+ pass
222
+
223
+ spec = convert_root(app)
224
+ all_flags = spec.flags
225
+ for c in spec.cmds:
226
+ all_flags = c.flags
227
+ port_flag = next((f for f in all_flags if f.long == "port"), None)
228
+ assert port_flag is not None
229
+ assert port_flag.default == ["0"]
230
+
231
+ def test_renders_choices(self):
232
+ app = typer.Typer()
233
+
234
+ @app.command()
235
+ def deploy(
236
+ env: str = typer.Option("dev", help="Environment", click_type=click.Choice(["dev", "staging", "prod"])),
237
+ ):
238
+ pass
239
+
240
+ spec = convert_root(app)
241
+ all_flags = spec.flags
242
+ for c in spec.cmds:
243
+ all_flags = c.flags
244
+ env_flag = next((f for f in all_flags if f.long == "env"), None)
245
+ assert env_flag is not None
246
+ assert env_flag.arg is not None
247
+ assert env_flag.arg.choices is not None
248
+ assert env_flag.arg.choices.values == ["dev", "staging", "prod"]
249
+
250
+ def test_renders_short_and_long_flags(self):
251
+ app = typer.Typer()
252
+
253
+ @app.command()
254
+ def run(
255
+ verbose: bool = typer.Option(False, "-v", "--verbose", help="Be verbose"),
256
+ output: str = typer.Option("dist", "-o", "--output", help="Output dir"),
257
+ ):
258
+ pass
259
+
260
+ output = generate(app)
261
+
262
+ assert "flag" in output
263
+
264
+ def test_renders_long_only_flags(self):
265
+ app = typer.Typer()
266
+
267
+ @app.command()
268
+ def run(
269
+ verbose: bool = typer.Option(False, "--verbose", help="Be verbose"),
270
+ ):
271
+ pass
272
+
273
+ output = generate(app)
274
+ assert "flag --verbose" in output
275
+
276
+ def test_hides_hidden_options(self):
277
+ app = typer.Typer()
278
+
279
+ @app.command()
280
+ def run(
281
+ secret: str = typer.Option("", hidden=True, help="Secret option"),
282
+ ):
283
+ pass
284
+
285
+ spec = convert_root(app)
286
+ all_flags = spec.flags
287
+ for c in spec.cmds:
288
+ all_flags = c.flags
289
+ secret_flag = next((f for f in all_flags if f.long == "secret"), None)
290
+ assert secret_flag is not None
291
+ assert secret_flag.hide is True
292
+
293
+ def test_handles_count_options(self):
294
+ app = typer.Typer()
295
+
296
+ @app.command()
297
+ def run(
298
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True, help="Verbosity level"),
299
+ ):
300
+ pass
301
+
302
+ spec = convert_root(app)
303
+ all_flags = spec.flags
304
+ for c in spec.cmds:
305
+ all_flags = c.flags
306
+ verbose_flag = next((f for f in all_flags if f.long == "verbose"), None)
307
+ assert verbose_flag is not None
308
+ assert verbose_flag.count is True
309
+
310
+ def test_handles_multiple_options(self):
311
+ app = typer.Typer()
312
+
313
+ @app.command()
314
+ def run(
315
+ include: Optional[list[str]] = typer.Option(None, "--include", help="Include patterns"),
316
+ ):
317
+ pass
318
+
319
+ # Typer's multiple option maps to click's multiple
320
+ spec = convert_root(app)
321
+ all_flags = spec.flags
322
+ for c in spec.cmds:
323
+ all_flags = c.flags
324
+ include_flag = next((f for f in all_flags if f.long == "include"), None)
325
+ if include_flag and include_flag.var:
326
+ assert include_flag.arg is not None
327
+ assert include_flag.arg.var is True
328
+
329
+
330
+ class TestBooleanFlags:
331
+ def test_renders_boolean_options(self):
332
+ app = typer.Typer()
333
+
334
+ @app.command()
335
+ def build(
336
+ verbose: bool = typer.Option(False, help="Be verbose"),
337
+ ):
338
+ pass
339
+
340
+ spec = convert_root(app)
341
+ all_flags = spec.flags
342
+ for c in spec.cmds:
343
+ all_flags = c.flags
344
+ verbose_flag = next((f for f in all_flags if f.long == "verbose"), None)
345
+ assert verbose_flag is not None
346
+ assert verbose_flag.arg is None
347
+
348
+ def test_renders_boolean_default_true(self):
349
+ app = typer.Typer()
350
+
351
+ @app.command()
352
+ def build(
353
+ color: bool = typer.Option(True, help="Enable color"),
354
+ ):
355
+ pass
356
+
357
+ spec = convert_root(app)
358
+ all_flags = spec.flags
359
+ for c in spec.cmds:
360
+ all_flags = c.flags
361
+ color_flag = next((f for f in all_flags if f.long == "color"), None)
362
+ assert color_flag is not None
363
+ assert color_flag.default_bool is True
364
+
365
+ def test_skips_false_default_for_boolean_flags(self):
366
+ app = typer.Typer()
367
+
368
+ @app.command()
369
+ def build(
370
+ force: bool = typer.Option(False, help="Force the operation"),
371
+ ):
372
+ pass
373
+
374
+ spec = convert_root(app)
375
+ all_flags = spec.flags
376
+ for c in spec.cmds:
377
+ all_flags = c.flags
378
+ force_flag = next((f for f in all_flags if f.long == "force"), None)
379
+ assert force_flag is not None
380
+ assert force_flag.default == []
381
+ assert force_flag.default_bool is None
382
+
383
+
384
+ class TestNegatedOptions:
385
+ def test_renders_negate_for_toggle_flags(self):
386
+ app = typer.Typer()
387
+
388
+ @app.command()
389
+ def run(
390
+ color: bool = typer.Option(True, "--color/--no-color", help="Color output"),
391
+ ):
392
+ pass
393
+
394
+ spec = convert_root(app)
395
+ all_flags = spec.flags
396
+ for c in spec.cmds:
397
+ all_flags = c.flags
398
+ color_flag = next((f for f in all_flags if f.long == "color"), None)
399
+ assert color_flag is not None
400
+ assert color_flag.negate == "--no-color"
401
+
402
+
403
+ class TestEnvironmentVariables:
404
+ def test_renders_env_for_options_with_envvar(self):
405
+ app = typer.Typer()
406
+
407
+ @app.command()
408
+ def run(
409
+ color: str = typer.Option("", envvar="MYCLI_COLOR", help="Color output"),
410
+ ):
411
+ pass
412
+
413
+ spec = convert_root(app)
414
+ all_flags = spec.flags
415
+ for c in spec.cmds:
416
+ all_flags = c.flags
417
+ color_flag = next((f for f in all_flags if f.long == "color"), None)
418
+ assert color_flag is not None
419
+ assert color_flag.env == "MYCLI_COLOR"
420
+
421
+ def test_has_no_env_when_envvar_not_set(self):
422
+ app = typer.Typer()
423
+
424
+ @app.command()
425
+ def run(
426
+ color: str = typer.Option("", help="Color output"),
427
+ ):
428
+ pass
429
+
430
+ spec = convert_root(app)
431
+ all_flags = spec.flags
432
+ for c in spec.cmds:
433
+ all_flags = c.flags
434
+ color_flag = next((f for f in all_flags if f.long == "color"), None)
435
+ assert color_flag is not None
436
+ assert color_flag.env == ""
437
+
438
+
439
+ class TestSkipsBuiltinFlags:
440
+ def test_does_not_render_typer_completion_flags(self):
441
+ app = typer.Typer()
442
+
443
+ @app.command()
444
+ def hello(name: str = typer.Option("world", help="Your name")):
445
+ pass
446
+
447
+ spec = convert_root(app)
448
+ all_flag_names = [f.long for f in spec.flags]
449
+ for c in spec.cmds:
450
+ all_flag_names.extend(f.long for f in c.flags)
451
+
452
+ assert "help" not in all_flag_names
453
+ assert "install-completion" not in all_flag_names
454
+ assert "show-completion" not in all_flag_names
455
+
456
+
457
+ class TestPositionalArguments:
458
+ def test_converts_required_arguments(self):
459
+ app = typer.Typer()
460
+
461
+ @app.command()
462
+ def hello(name: str = typer.Argument(help="Your name")):
463
+ pass
464
+
465
+ spec = convert_root(app)
466
+ assert len(spec.args) >= 1
467
+ assert spec.args[0].name == "name"
468
+ assert spec.args[0].required is True
469
+
470
+ def test_converts_optional_arguments(self):
471
+ app = typer.Typer()
472
+
473
+ @app.command()
474
+ def hello(name: Optional[str] = typer.Argument(default=None, help="Your name")):
475
+ pass
476
+
477
+ spec = convert_root(app)
478
+ assert len(spec.args) >= 1
479
+ assert spec.args[0].name == "name"
480
+ assert spec.args[0].required is False
481
+
482
+ def test_converts_variadic_arguments(self):
483
+ app = typer.Typer()
484
+
485
+ @app.command()
486
+ def run(files: list[str] = typer.Argument(help="Input files")):
487
+ pass
488
+
489
+ spec = convert_root(app)
490
+ files_arg = next((a for a in spec.args if a.name == "files"), None)
491
+ if files_arg:
492
+ assert files_arg.var is True
493
+
494
+ def test_converts_mixed_required_and_optional_args(self):
495
+ app = typer.Typer()
496
+
497
+ @app.command()
498
+ def copy(
499
+ source: str = typer.Argument(help="Source path"),
500
+ dest: Optional[str] = typer.Argument(default=None, help="Destination path"),
501
+ ):
502
+ pass
503
+
504
+ spec = convert_root(app)
505
+ assert len(spec.args) >= 2
506
+ assert spec.args[0].name == "source"
507
+ assert spec.args[0].required is True
508
+ assert spec.args[1].name == "dest"
509
+ assert spec.args[1].required is False
510
+
511
+ def test_converts_choices_on_arguments(self):
512
+ app = typer.Typer()
513
+
514
+ @app.command()
515
+ def deploy(env: str = typer.Argument(help="Environment", click_type=click.Choice(["dev", "staging", "prod"]))):
516
+ pass
517
+
518
+ spec = convert_root(app)
519
+ env_arg = next((a for a in spec.args if a.name == "env"), None)
520
+ if env_arg:
521
+ assert env_arg.choices is not None
522
+ assert env_arg.choices.values == ["dev", "staging", "prod"]
523
+
524
+ def test_converts_hidden_arguments(self):
525
+ app = typer.Typer()
526
+
527
+ @app.command()
528
+ def run(
529
+ name: str = typer.Argument(help="Your name", hidden=True),
530
+ ):
531
+ pass
532
+
533
+ spec = convert_root(app)
534
+ name_arg = next((a for a in spec.args if a.name == "name"), None)
535
+ if name_arg:
536
+ assert name_arg.hide is True
537
+
538
+ def test_converts_default_on_arguments(self):
539
+ app = typer.Typer()
540
+
541
+ @app.command()
542
+ def hello(name: str = typer.Argument(default="world", help="Your name")):
543
+ pass
544
+
545
+ spec = convert_root(app)
546
+ name_arg = next((a for a in spec.args if a.name == "name"), None)
547
+ if name_arg:
548
+ assert name_arg.default == ["world"]
549
+
550
+
551
+ class TestSubcommandRequired:
552
+ def test_sets_subcommand_required_for_groups(self):
553
+ app = typer.Typer()
554
+ config_app = typer.Typer()
555
+ app.add_typer(config_app, name="config")
556
+
557
+ @config_app.command()
558
+ def get():
559
+ """Get a value"""
560
+
561
+ @config_app.command()
562
+ def set_():
563
+ """Set a value"""
564
+
565
+ spec = convert_root(app)
566
+ config_cmd = next((c for c in spec.cmds if c.name == "config"), None)
567
+ if config_cmd:
568
+ assert config_cmd.subcommand_required is True
569
+
570
+
571
+ class TestLongHelp:
572
+ def test_renders_long_help_for_subgroup(self):
573
+ app = typer.Typer(help="App help")
574
+ db_app = typer.Typer(help="Database operations")
575
+ app.add_typer(db_app, name="db")
576
+
577
+ @db_app.command()
578
+ def migrate():
579
+ """Run migrations"""
580
+
581
+ spec = convert_root(app)
582
+ db_cmd = next((c for c in spec.cmds if c.name == "db"), None)
583
+ assert db_cmd is not None
584
+ assert db_cmd.help == "Database operations"
585
+
586
+
587
+ class TestSpecialCharacterEscaping:
588
+ def test_handles_special_chars_in_flag_help(self):
589
+ app = typer.Typer()
590
+
591
+ @app.command()
592
+ def run(
593
+ format: str = typer.Option("json", help="Output format:\n json\n yaml"),
594
+ ):
595
+ pass
596
+
597
+ output = generate(app)
598
+ assert "help=" in output
599
+ kdl_content = output.split("\n", 1)[1] if output.startswith("//") else output
600
+ validate_kdl(kdl_content)
601
+
602
+ def test_handles_newlines_in_command_help(self):
603
+ app = typer.Typer()
604
+
605
+ @app.command()
606
+ def run():
607
+ """First line.
608
+ Second line."""
609
+
610
+ output = generate(app)
611
+ kdl_content = output.split("\n", 1)[1] if output.startswith("//") else output
612
+ validate_kdl(kdl_content)
613
+
614
+
615
+ class TestKDLRoundTrip:
616
+ def test_generates_valid_kdl(self):
617
+ app = typer.Typer(help="A CLI")
618
+
619
+ @app.command()
620
+ def hello(name: str = typer.Argument(help="Your name")):
621
+ pass
622
+
623
+ output = generate(app)
624
+ kdl_content = output.split("\n", 1)[1] if output.startswith("//") else output
625
+ validate_kdl(kdl_content)
626
+
627
+ def test_generates_valid_kdl_for_nested_commands(self):
628
+ app = typer.Typer()
629
+ config_app = typer.Typer()
630
+ app.add_typer(config_app, name="config")
631
+
632
+ @config_app.command()
633
+ def get():
634
+ """Get value"""
635
+
636
+ output = generate(app)
637
+ kdl_content = output.split("\n", 1)[1] if output.startswith("//") else output
638
+ validate_kdl(kdl_content)
639
+
640
+ def test_generates_valid_kdl_with_flags_and_args(self):
641
+ app = typer.Typer()
642
+
643
+ @app.command()
644
+ def run(
645
+ verbose: bool = typer.Option(False, help="Be verbose"),
646
+ name: str = typer.Argument(help="Name"),
647
+ ):
648
+ pass
649
+
650
+ output = generate(app)
651
+ kdl_content = output.split("\n", 1)[1] if output.startswith("//") else output
652
+ validate_kdl(kdl_content)
653
+
654
+
655
+ class TestGenerateKDL:
656
+ def test_is_alias_for_generate(self):
657
+ app = typer.Typer()
658
+
659
+ @app.command()
660
+ def hello():
661
+ pass
662
+
663
+ assert generate_kdl(app) == generate(app)
664
+
665
+
666
+ class TestConvertRoot:
667
+ def test_returns_spec_with_correct_structure(self):
668
+ app = typer.Typer(help="A CLI tool")
669
+
670
+ @app.command()
671
+ def hello(
672
+ verbose: bool = typer.Option(False, help="Be verbose"),
673
+ ):
674
+ pass
675
+
676
+ spec = convert_root(app, "mycli")
677
+
678
+ assert spec.name == "mycli"
679
+ assert spec.bin == "mycli"
680
+ assert spec.about == "A CLI tool"
681
+
682
+ def test_includes_arguments_in_spec(self):
683
+ app = typer.Typer()
684
+
685
+ @app.command()
686
+ def hello(
687
+ name: str = typer.Argument(help="Your name"),
688
+ greeting: Optional[str] = typer.Argument(default=None, help="Custom greeting"),
689
+ ):
690
+ pass
691
+
692
+ spec = convert_root(app)
693
+
694
+ assert len(spec.args) >= 2
695
+ assert spec.args[0].name == "name"
696
+ assert spec.args[0].required is True
697
+ assert spec.args[1].name == "greeting"
698
+ assert spec.args[1].required is False
699
+
700
+
701
+ class TestJSONOutput:
702
+ def test_generates_json_output_with_correct_structure(self):
703
+ app = typer.Typer(help="A CLI tool")
704
+
705
+ @app.command()
706
+ def hello(name: str = typer.Argument(help="Your name")):
707
+ pass
708
+
709
+ j = generate_json(app, "mycli")
710
+ parsed = json.loads(j)
711
+
712
+ assert parsed["name"] == "mycli"
713
+ assert parsed["bin"] == "mycli"
714
+ assert parsed["about"] == "A CLI tool"
715
+
716
+ def test_includes_choices_in_json_output(self):
717
+ app = typer.Typer()
718
+
719
+ @app.command()
720
+ def deploy(env: str = typer.Option("dev", help="Environment", click_type=click.Choice(["dev", "prod"]))):
721
+ pass
722
+
723
+ j = generate_json(app)
724
+ parsed = json.loads(j)
725
+
726
+ # Find the env flag wherever it is
727
+ all_flags = parsed.get("flags", [])
728
+ for cmd in parsed.get("cmds", []):
729
+ all_flags.extend(cmd.get("flags", []))
730
+ env_flag = next((f for f in all_flags if f.get("long") == "env"), None)
731
+ if env_flag and env_flag.get("arg"):
732
+ assert env_flag["arg"]["choices"] == ["dev", "prod"]
733
+
734
+ def test_includes_flag_details_in_json(self):
735
+ app = typer.Typer()
736
+
737
+ @app.command()
738
+ def deploy(
739
+ env: str = typer.Option(..., help="Target environment"),
740
+ ):
741
+ pass
742
+
743
+ j = generate_json(app)
744
+ parsed = json.loads(j)
745
+
746
+ all_flags = parsed.get("flags", [])
747
+ for cmd in parsed.get("cmds", []):
748
+ all_flags.extend(cmd.get("flags", []))
749
+ env_flag = next((f for f in all_flags if f.get("long") == "env"), None)
750
+ if env_flag:
751
+ assert env_flag["required"] is True
752
+ assert env_flag["arg"] is not None
753
+
754
+ def test_includes_args_in_json_output(self):
755
+ app = typer.Typer()
756
+
757
+ @app.command()
758
+ def hello(
759
+ name: str = typer.Argument(help="Your name"),
760
+ greeting: Optional[str] = typer.Argument(default=None, help="Custom greeting"),
761
+ ):
762
+ pass
763
+
764
+ j = generate_json(app)
765
+ parsed = json.loads(j)
766
+
767
+ args = parsed.get("args", [])
768
+ assert len(args) >= 2
769
+ assert args[0]["name"] == "name"
770
+ # required=true is default, omitted from JSON
771
+ assert "required" not in args[0]
772
+ assert args[1]["name"] == "greeting"
773
+ assert args[1]["required"] is False
774
+
775
+ def test_handles_custom_bin_name_in_json(self):
776
+ app = typer.Typer()
777
+
778
+ @app.command()
779
+ def hello():
780
+ pass
781
+
782
+ j = generate_json(app, "my-tool")
783
+ parsed = json.loads(j)
784
+
785
+ assert parsed["bin"] == "my-tool"
786
+
787
+
788
+ class TestEdgeCases:
789
+ def test_handles_empty_app_with_no_commands(self):
790
+ app = typer.Typer()
791
+ spec = convert_root(app)
792
+
793
+ assert spec.name == ""
794
+ assert spec.flags == []
795
+ assert spec.args == []
796
+ assert spec.cmds == []
797
+
798
+ def test_handles_app_with_only_subcommands(self):
799
+ app = typer.Typer()
800
+
801
+ @app.command()
802
+ def start():
803
+ """Start"""
804
+
805
+ @app.command()
806
+ def stop():
807
+ """Stop"""
808
+
809
+ output = generate(app)
810
+
811
+ assert "cmd start" in output
812
+ assert "cmd stop" in output
813
+
814
+ def test_handles_deeply_nested_command_structure(self):
815
+ app = typer.Typer()
816
+ db_app = typer.Typer(help="Database operations")
817
+ app.add_typer(db_app, name="db")
818
+
819
+ migrate_app = typer.Typer(help="Run migrations")
820
+ db_app.add_typer(migrate_app, name="migrate")
821
+
822
+ @migrate_app.command()
823
+ def up():
824
+ """Apply migrations"""
825
+
826
+ @migrate_app.command()
827
+ def down():
828
+ """Rollback migrations"""
829
+
830
+ output = generate(app)
831
+
832
+ assert "cmd db" in output
833
+ assert "cmd migrate" in output
834
+ assert "cmd up" in output
835
+ assert "cmd down" in output
836
+
837
+ def test_single_command_app_treats_command_as_root(self):
838
+ app = typer.Typer()
839
+
840
+ @app.command()
841
+ def hello(name: str = typer.Argument(help="Your name")):
842
+ """Say hello"""
843
+
844
+ spec = convert_root(app)
845
+ # Single command: its flags/args become root-level
846
+ assert len(spec.args) >= 1
847
+ assert spec.args[0].name == "name"
848
+
849
+
850
+ class TestFullOutputFormat:
851
+ def test_matches_expected_kdl_output(self):
852
+ app = typer.Typer(help="An example CLI")
853
+
854
+ @app.command()
855
+ def build(
856
+ output: str = typer.Option("dist", help="Output directory"),
857
+ verbose: bool = typer.Option(False, help="Enable verbose output"),
858
+ ):
859
+ """Build the project"""
860
+
861
+ @app.command()
862
+ def test():
863
+ """Run tests"""
864
+
865
+ output = generate(app, "example")
866
+
867
+ assert "name example" in output
868
+ assert "bin example" in output
869
+ assert 'about "An example CLI"' in output
870
+ assert "cmd build" in output
871
+ assert "cmd test" in output
872
+
873
+ def test_full_output_with_version(self):
874
+ app = typer.Typer(help="An example CLI")
875
+
876
+ @app.callback()
877
+ def main():
878
+ pass
879
+
880
+ @app.command()
881
+ def run():
882
+ """Run the app"""
883
+
884
+ output = generate(app, "example")
885
+
886
+ assert "name example" in output
887
+ assert "bin example" in output