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.
- usage_spec_typer-1.0.0/.gitignore +8 -0
- usage_spec_typer-1.0.0/PKG-INFO +99 -0
- usage_spec_typer-1.0.0/README.md +76 -0
- usage_spec_typer-1.0.0/pyproject.toml +35 -0
- usage_spec_typer-1.0.0/src/typer_usage/__init__.py +39 -0
- usage_spec_typer-1.0.0/src/typer_usage/convert.py +139 -0
- usage_spec_typer-1.0.0/tests/test_typer.py +887 -0
|
@@ -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
|