A Python CLI framework built on argparse that provides a extensible supercommand pattern for building command-line tools with nested subcommands.
src/
├── cli.py # Core CLI framework (SupercommandArgumentParser)
└── states/
└── cli.py # States CLI module
src/cli.py: Core framework providingSupercommandArgumentParsersrc/states/cli.py: Defines a client class with methods that each represent a subcommand. Each method's docstring describes its parameters, which are automatically parsed by the framework into CLI arguments
cli [--version] [--help] <command> [<args>]
Register a function as a subcommand by passing it to add_parser_func():
def my_command(args):
"""Help text derived from the docstring."""
return {"status": "ok"}
parser.add_parser_func(my_command)
# Available as: cli my-commandMethods on the client class in src/states/cli.py follow the same pattern - each method becomes a subcommand, and parameter descriptions in the docstring are automatically converted to CLI arguments:
class StatesClient:
def get_state(self, args):
"""Get the current state.
state_id: The ID of the state to retrieve
format: Output format (json or text)
"""
...- SupercommandArgumentParser: extended
argparse.ArgumentParserthat supports dynamically registering functions as subcommands viaadd_parser_func(). Subcommand names are automatically derived from function names (underscores converted to hyphens). - Shell autocompletion: via
argcomplete - Structured output: command return values are automatically serialized as pretty-printed JSON, with fallback
str()handling for non-serializable types (e.g.datetime) - Version reporting:
--versionincludes the Python implementation, version, and platform
- Python 3
argcomplete
Say you have a Python class that talks to your infrastructure - listing hosts, checking deploy status, restarting services. You want to expose it as a CLI so your team can use it from the terminal and pipe the JSON output into jq, scripts, etc.
Here's the class. Each method becomes a subcommand. The docstring's first line becomes the help text, and the parameter lines become CLI arguments:
# infra/client.py
class InfraClient:
def __init__(self, api):
self.api = api
def list_hosts(self, args):
"""List all hosts, optionally filtered by environment.
--env: Filter by environment (staging, production)
"""
hosts = self.api.get_hosts()
if hasattr(args, "env") and args.env:
hosts = [h for h in hosts if h["env"] == args.env]
return hosts
def get_host(self, args):
"""Get details for a specific host.
hostname: The hostname to look up
"""
return self.api.get_host(args.hostname)
def restart_service(self, args):
"""Restart a service on a given host.
hostname: Target host
service: Service name to restart
--force: Skip the graceful shutdown period
"""
return self.api.restart(args.hostname, args.service, force=args.force)Here's the wiring. The add_commands function iterates over the client's methods and registers each one as a subcommand, same pattern used in src/states/cli.py:
# infra/cli.py
def add_commands(subparsers, help_menu=False):
client = InfraClient(api=get_api_connection())
sub = subparsers.add_parser("list-hosts", help="List all hosts")
sub.add_argument("--env", choices=["staging", "production"])
sub.set_defaults(entry_point=client.list_hosts)
sub = subparsers.add_parser("get-host", help="Get details for a specific host")
sub.add_argument("hostname")
sub.set_defaults(entry_point=client.get_host)
sub = subparsers.add_parser("restart-service", help="Restart a service on a given host")
sub.add_argument("hostname")
sub.add_argument("service")
sub.add_argument("--force", action="store_true")
sub.set_defaults(entry_point=client.restart_service)Now from the terminal:
$ infra list-hosts --env production
[
{"hostname": "web-1", "env": "production", "status": "healthy"},
{"hostname": "web-2", "env": "production", "status": "degraded"}
]
$ infra get-host web-1
{
"hostname": "web-1",
"env": "production",
"status": "healthy",
"uptime": "14 days",
"services": ["nginx", "app-server", "datadog-agent"]
}
$ infra restart-service web-2 nginx --force
{
"hostname": "web-2",
"service": "nginx",
"action": "restart",
"forced": true,
"previous_status": "degraded",
"new_status": "healthy"
}
$ infra list-hosts --env production | jq '.[].hostname'
"web-1"
"web-2"
The point: the framework handles subcommand routing, help text, and JSON serialization. All you do is write the client class with the actual logic, wire each method up as a subcommand, and the CLI is done - including shell autocompletion and --help for every command.