diff --git a/.gitignore b/.gitignore index f964c8d..f7a9ded 100644 --- a/.gitignore +++ b/.gitignore @@ -122,3 +122,4 @@ CLAUDE.md GEMINI.md .claude/ .gemini/ +AGENTS.md diff --git a/bin/run-examples.sh b/bin/run-examples.sh index 71b9ada..850a0bb 100755 --- a/bin/run-examples.sh +++ b/bin/run-examples.sh @@ -6,9 +6,11 @@ cd "$(dirname "$0")/.." || exit 1 uv run coverage run -a -m examples.simple -h +uv run coverage run -a -m examples.simple --help-json uv run coverage run -a -m examples.simple 1 --baz 2 uv run coverage run -a -m examples.simple foo 1 --baz 2 uv run coverage run -a -m examples -h +uv run coverage run -a -m examples --help-json uv run coverage run -a -m examples foo 1 --foo2 foo --foo12 '{"foo": 1, "bar": "baz"}' --foo-bar 1 uv run coverage run -a -m examples sub -h uv run coverage run -a -m examples foo -h @@ -16,9 +18,11 @@ uv run coverage run -a -m examples error || true uv run coverage run -a -m examples fooo || true # Did you mean 'foo'? uv run coverage run -a -m examples sub foo 1 --foo2 foo uv run coverage run -a -m examples.derived -h +uv run coverage run -a -m examples.derived --help-json uv run coverage run -a -m examples.derived bar uv run coverage run -a -m examples.derived foo --host postgres uv run coverage run -a -m examples.derived dynamic --db1 test uv run coverage run -a -m examples.annotated -h +uv run coverage run -a -m examples.annotated --help-json uv run coverage run -a -m examples.annotated foo 42 -b hello --foo bar uv run coverage run -a -m examples.annotated derived -a 3 -b 2 --mode release diff --git a/examples/derived.py b/examples/derived.py index accf5f3..fbc8859 100644 --- a/examples/derived.py +++ b/examples/derived.py @@ -1,16 +1,16 @@ import asyncio import os from typing import Literal -from piou import Cli, Option, Derived +from piou import Cli, Option, Derived, Password cli = Cli(description="A CLI tool") -PgPwd = Option(os.getenv("PG_PWD", "pwd"), "--pwd", help="PG Host") +PgPwd = Option(os.getenv("PG_PWD", "pwd"), "--pwd", help="PG Password") def get_pg_url( pg_user: str = Option("postgres", "--user"), - pg_pwd: str = PgPwd, + pg_pwd: Password = PgPwd, pg_host: str = Option("localhost", "--host"), pg_port: int = Option(5432, "--port"), pg_db: str = Option("postgres", "--db"), diff --git a/piou/cli.py b/piou/cli.py index 5d54af1..01ca940 100644 --- a/piou/cli.py +++ b/piou/cli.py @@ -4,7 +4,7 @@ from dataclasses import dataclass, field from typing import Any, Callable, overload -from .command import CommandGroup, ShowHelpError, ShowTuiError, clean_multiline, OnCommandRun +from .command import CommandGroup, ShowHelpError, ShowHelpJsonError, ShowTuiError, clean_multiline, OnCommandRun from .utils import cleanup_event_loop from .exceptions import ( CommandNotFoundError, @@ -91,6 +91,12 @@ def run_with_args(self, *args): sys.exit(1) except ShowHelpError as e: self.formatter.print_help(group=e.group, command=e.command, parent_args=e.parent_args) + except ShowHelpJsonError as e: + from .help_json import print_help_json + + # Show the command if targeting a specific one, otherwise the group + command = e.command if e.command and e.command.name != "__main__" else None + print_help_json(e.group, command, e.resolve_choices) except ShowTuiError as e: self.tui_run(inline=e.inline, dev=e.dev) except KeywordParamNotFoundError as e: diff --git a/piou/command.py b/piou/command.py index 7615954..2041d74 100644 --- a/piou/command.py +++ b/piou/command.py @@ -303,6 +303,15 @@ def run_with_args( return command_group.run_with_args(*cmd_options, parent_args=parent_args, loop=loop) _all_options = set(global_options + cmd_options) + # Checks if JSON help was requested + _help_json = _all_options & {"--help-json", "--help-json=full"} + if _help_json: + raise ShowHelpJsonError( + group=command_group or self, + parent_args=parent_args, + command=command, + resolve_choices="--help-json=full" in _help_json, + ) # Checks if help was requested and raises a special exception to display help if _all_options & {"-h", "--help"}: raise ShowHelpError(group=command_group or self, parent_args=parent_args, command=command) @@ -397,6 +406,22 @@ def __init__( self.parent_args = parent_args +class ShowHelpJsonError(Exception): + """Exception raised to output the CLI schema as JSON.""" + + def __init__( + self, + group: CommandGroup, + command: Command | None = None, + parent_args: ParentArgs | None = None, + resolve_choices: bool = False, + ): + self.command = command + self.group = group + self.parent_args = parent_args + self.resolve_choices = resolve_choices + + class ShowTuiError(Exception): """Exception raised to run TUI mode.""" diff --git a/piou/help_json.py b/piou/help_json.py new file mode 100644 index 0000000..cde8b7a --- /dev/null +++ b/piou/help_json.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import json +import re +import sys +from types import NoneType, UnionType +from typing import Any, Literal, Union, get_args, get_origin + +from .command import Command, CommandGroup +from .utils import CommandOption, run_function + + +def print_help_json( + group: CommandGroup, + command: Command | None, + resolve_choices: bool, +): + """Print JSON schema of CLI to stdout.""" + if command: + data = _serialize_command(command, resolve_choices) + else: + data = _serialize_group(group, resolve_choices) + json.dump(data, sys.stdout, indent=2, default=str) + print() + + +def _serialize_group(group: CommandGroup, resolve_choices: bool) -> dict[str, Any]: + result: dict[str, Any] = {"name": group.name} + if group.help: + result["help"] = group.help + if group.description: + result["description"] = group.description + if group.options: + result["options"] = [_serialize_option(o, resolve_choices) for o in group.options] + commands: dict[str, Any] = {} + for name, cmd in group.commands.items(): + if name == "__main__": + continue + if isinstance(cmd, CommandGroup): + commands[name] = _serialize_group(cmd, resolve_choices) + else: + commands[name] = _serialize_command(cmd, resolve_choices) + if commands: + result["commands"] = commands + return result + + +def _serialize_command(cmd: Command, resolve_choices: bool) -> dict[str, Any]: + result: dict[str, Any] = {"name": cmd.name} + if cmd.help: + result["help"] = cmd.help + if cmd.description: + result["description"] = cmd.description + if cmd.options: + result["arguments"] = [_serialize_option(o, resolve_choices) for o in cmd.options_sorted] + return result + + +def _serialize_option(opt: CommandOption, resolve_choices: bool) -> dict[str, Any]: + result: dict[str, Any] = {"name": opt.name, "type": _type_name(opt.data_type)} + if opt.keyword_args: + result["flags"] = list(opt.keyword_args) + if opt.negative_flag: + result["negative_flag"] = opt.negative_flag + result["required"] = opt.is_required + if not opt.is_required and not opt.is_secret: + result["default"] = opt.default + if opt.help: + result["help"] = opt.help + if not opt.hide_choices: + choices = _get_choices(opt, resolve_choices) + if choices: + if isinstance(choices, str): + result["choices"] = choices + else: + result["choices"] = [str(c) if isinstance(c, re.Pattern) else c for c in choices] + if opt.is_positional_arg: + result["positional"] = True + return result + + +def _get_choices(opt: CommandOption, resolve: bool) -> list | str | None: + if opt.literal_values: + return opt.literal_values + if opt.choices is None: + return None + if callable(opt.choices): + if not resolve: + return "" + return run_function(opt.choices) + return opt.choices + + +def _type_name(t: type) -> str: + origin = get_origin(t) + if origin is Union or origin is UnionType: + args = [a for a in get_args(t) if a is not NoneType] + if len(args) == 1: + return _type_name(args[0]) + return " | ".join(_type_name(a) for a in args) + if origin is Literal: + args = get_args(t) + if args: + return type(args[0]).__name__ + return getattr(t, "__name__", str(t)) diff --git a/tests/test_help_json.py b/tests/test_help_json.py new file mode 100644 index 0000000..c4d5b7a --- /dev/null +++ b/tests/test_help_json.py @@ -0,0 +1,256 @@ +import json +from typing import Annotated, Literal + +from piou import Cli, Option, Derived + + +def _make_cli(): + cli = Cli(description="Test CLI") + + @cli.command(cmd="greet", help="Say hello") + def greet(name: str = Option(..., help="Name to greet")): + pass + + @cli.command(cmd="deploy", help="Deploy app", description="Deploy the application") + def deploy( + env: Literal["prod", "staging"] = Option(..., "-e", "--env", help="Target environment"), + force: bool = Option(False, "-f", "--force", help="Force deploy"), + ): + pass + + return cli + + +def _capture_help_json(cli, *args) -> dict: + import io + import sys + + old_stdout = sys.stdout + sys.stdout = buf = io.StringIO() + try: + cli.run_with_args(*args) + finally: + sys.stdout = old_stdout + return json.loads(buf.getvalue()) + + +class TestBasicCli: + def test_root_structure(self): + cli = _make_cli() + data = _capture_help_json(cli, "--help-json") + assert data["name"] is None + assert "commands" in data + assert set(data["commands"]) == {"greet", "deploy"} + + def test_command_structure(self): + cli = _make_cli() + data = _capture_help_json(cli, "--help-json") + greet = data["commands"]["greet"] + assert greet["name"] == "greet" + assert greet["help"] == "Say hello" + assert len(greet["arguments"]) == 1 + arg = greet["arguments"][0] + assert arg["name"] == "name" + assert arg["type"] == "str" + assert arg["required"] is True + assert arg["positional"] is True + assert arg["help"] == "Name to greet" + + def test_command_with_flags(self): + cli = _make_cli() + data = _capture_help_json(cli, "--help-json") + deploy = data["commands"]["deploy"] + args_by_name = {a["name"]: a for a in deploy["arguments"]} + env = args_by_name["env"] + assert env["flags"] == ["-e", "--env"] + assert env["required"] is True + assert env["choices"] == ["prod", "staging"] + force = args_by_name["force"] + assert force["flags"] == ["-f", "--force"] + assert force["required"] is False + assert force["default"] is False + + def test_description(self): + cli = _make_cli() + data = _capture_help_json(cli, "--help-json") + deploy = data["commands"]["deploy"] + assert deploy["description"] == "Deploy the application" + + def test_subcommand_help_json(self): + cli = _make_cli() + data = _capture_help_json(cli, "greet", "--help-json") + assert data["name"] == "greet" + assert data["help"] == "Say hello" + + +class TestNestedGroups: + def test_nested_group(self): + cli = Cli(description="Nested CLI") + db = cli.add_command_group("db", help="Database commands") + + @db.command(cmd="migrate", help="Run migrations") + def migrate(version: int = Option(..., "-v", "--version")): + pass + + data = _capture_help_json(cli, "--help-json") + db_data = data["commands"]["db"] + assert db_data["name"] == "db" + assert db_data["help"] == "Database commands" + assert "migrate" in db_data["commands"] + mig = db_data["commands"]["migrate"] + assert mig["arguments"][0]["name"] == "version" + assert mig["arguments"][0]["type"] == "int" + + def test_nested_subcommand_help_json(self): + cli = Cli(description="Nested CLI") + db = cli.add_command_group("db", help="Database commands") + + @db.command(cmd="migrate", help="Run migrations") + def migrate(version: int = Option(..., "-v", "--version")): + pass + + data = _capture_help_json(cli, "db", "--help-json") + assert data["name"] == "db" + assert "migrate" in data["commands"] + + +class TestChoices: + def test_static_choices(self): + cli = Cli() + + @cli.command(cmd="run") + def run(mode: str = Option("fast", "--mode", choices=["fast", "slow"])): + pass + + data = _capture_help_json(cli, "--help-json") + arg = data["commands"]["run"]["arguments"][0] + assert arg["choices"] == ["fast", "slow"] + + def test_dynamic_choices_not_resolved(self): + cli = Cli() + + @cli.command(cmd="run") + def run(mode: str = Option("a", "--mode", choices=lambda: ["a", "b", "c"])): + pass + + data = _capture_help_json(cli, "--help-json") + arg = data["commands"]["run"]["arguments"][0] + assert arg["choices"] == "" + + def test_dynamic_choices_resolved(self): + cli = Cli() + + @cli.command(cmd="run") + def run(mode: str = Option("a", "--mode", choices=lambda: ["a", "b", "c"])): + pass + + data = _capture_help_json(cli, "--help-json=full") + arg = data["commands"]["run"]["arguments"][0] + assert arg["choices"] == ["a", "b", "c"] + + def test_hidden_choices(self): + from piou.utils import CommandOption + + cli = Cli() + opt = CommandOption("x", keyword_args=("--mode",), choices=["x", "y"], hide_choices=True) + + @cli.command(cmd="run") + def run(mode: Annotated[str, opt]): + pass + + data = _capture_help_json(cli, "--help-json") + arg = data["commands"]["run"]["arguments"][0] + assert "choices" not in arg + + def test_literal_choices(self): + cli = Cli() + + @cli.command(cmd="run") + def run(env: Literal["dev", "prod"] = Option("dev", "--env")): + pass + + data = _capture_help_json(cli, "--help-json") + arg = data["commands"]["run"]["arguments"][0] + assert arg["choices"] == ["dev", "prod"] + + +class TestSecrets: + def test_secret_default_hidden(self): + from piou.utils import Secret + + cli = Cli() + + @cli.command(cmd="login") + def login(token: Secret = Option("s3cret", "--token")): + pass + + data = _capture_help_json(cli, "--help-json") + arg = data["commands"]["login"]["arguments"][0] + assert "default" not in arg + assert arg["required"] is False + + +class TestMainCommand: + def test_main_excluded(self): + cli = Cli() + + @cli.command(cmd="visible", help="Visible") + def visible(): + pass + + @cli.main(help="Main command") + def main_cmd(x: str = Option("a", "--x")): + pass + + data = _capture_help_json(cli, "--help-json") + assert "__main__" not in data.get("commands", {}) + assert "visible" in data["commands"] + + +class TestDerived: + def test_derived_options_serialized(self): + cli = Cli() + + def make_greeting(name: str = Option(..., "--name"), upper: bool = Option(False, "--upper")) -> str: + return name.upper() if upper else name + + @cli.command(cmd="hello", help="Hello") + def hello(greeting: Annotated[str, Derived(make_greeting)]): + pass + + data = _capture_help_json(cli, "--help-json") + args_by_name = {a["name"]: a for a in data["commands"]["hello"]["arguments"]} + assert "name" in args_by_name + assert "upper" in args_by_name + # __processor.* args should be filtered + assert not any(n.startswith("__") for n in args_by_name) + + +class TestNegativeFlags: + def test_negative_flag_in_output(self): + cli = Cli() + + @cli.command(cmd="run") + def run(verbose: bool = Option(True, "--verbose/--no-verbose")): + pass + + data = _capture_help_json(cli, "--help-json") + arg = data["commands"]["run"]["arguments"][0] + assert arg["flags"] == ["--verbose"] + assert arg["negative_flag"] == "--no-verbose" + + +class TestGroupOptions: + def test_group_options(self): + cli = Cli() + cli.add_option("-v", "--verbose", help="Verbose output") + + @cli.command(cmd="run") + def run(): + pass + + data = _capture_help_json(cli, "--help-json") + assert "options" in data + opt = data["options"][0] + assert opt["name"] == "verbose" + assert opt["flags"] == ["-v", "--verbose"] diff --git a/uv.lock b/uv.lock index 4558a02..3d87499 100644 --- a/uv.lock +++ b/uv.lock @@ -1209,7 +1209,7 @@ wheels = [ [[package]] name = "piou" -version = "0.33.0" +version = "0.33.1" source = { editable = "." } dependencies = [ { name = "rich" },