Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,4 @@ CLAUDE.md
GEMINI.md
.claude/
.gemini/
AGENTS.md
4 changes: 4 additions & 0 deletions bin/run-examples.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,23 @@ 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
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
6 changes: 3 additions & 3 deletions examples/derived.py
Original file line number Diff line number Diff line change
@@ -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"),
Expand Down
8 changes: 7 additions & 1 deletion piou/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
25 changes: 25 additions & 0 deletions piou/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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."""

Expand Down
105 changes: 105 additions & 0 deletions piou/help_json.py
Original file line number Diff line number Diff line change
@@ -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 "<dynamic>"
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))
Loading
Loading