Skip to content

Instantly share code, notes, and snippets.

@charlesreid1
Last active March 25, 2026 16:26
Show Gist options
  • Select an option

  • Save charlesreid1/f47b6c247c829864d3ed88835891ee8f to your computer and use it in GitHub Desktop.

Select an option

Save charlesreid1/f47b6c247c829864d3ed88835891ee8f to your computer and use it in GitHub Desktop.
Dynamically turn all class methods into CLI supercommands

supercommand_cli.py

A Python CLI framework built on argparse that provides a extensible supercommand pattern for building command-line tools with nested subcommands.

Project Structure

src/
├── cli.py              # Core CLI framework (SupercommandArgumentParser)
└── states/
    └── cli.py          # States CLI module
  • src/cli.py: Core framework providing SupercommandArgumentParser
  • src/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

Usage

cli [--version] [--help] <command> [<args>]

Adding Commands

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-command

Methods 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)
        """
        ...

Features

  • SupercommandArgumentParser: extended argparse.ArgumentParser that supports dynamically registering functions as subcommands via add_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: --version includes the Python implementation, version, and platform

Requirements

  • Python 3
  • argcomplete

Example: Turning a Service Client into a CLI

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.

"""Console script"""
import sys
import os
import json
import argparse
import logging
import platform
import traceback
import argcomplete
from .states import cli as states_cli
from . import __version__
class SupercommandArgumentParser(argparse.ArgumentParser):
"""
Super command argument parser.
A super command is an argparse command with sub-commands
that can be attached to it.
This is implemented as an extension of the argparse.ArgParser
class, but it also stores a list of subparsers in a
private attribute self._subparsers.
argument parsers can be registered as subparsers of this
parent argument parser by registering with the add_parser_func()
method.
"""
def __init__(self, *args, **kwargs):
super(*args, **kwargs)
self._subparsers = None
def add_parser_func(self, func, **kwargs):
if self._subparsers is None:
self._subparsers = self.add_subparsers()
# we were passed a function, add a subparser action corresponding to it.
# auto-generate a safe name for this parser based on the function name.
# func.__name__ is the magic here.
subparser = self._subparsers.add_parser(func.__name__.replace("_", "-"), **kwargs)
# set the entry point for this command line option to execute the function
subparser.set_defaults(entry_point=func)
# trust fall
command = subparser.prog[len(self.prog) + 1 :].replace("-", "_").replace(" ", "_")
subparser.set_defaults({})
# If the subparser has a description, use that.
# Otherwise, if the "help" keyword arg was provided, use that.
# Otherwise, use the docstring.
if subparser.description is None:
subparser.description = kwargs.get("help", func.__doc__)
# Not sure why this is necessary...
self._defaults.pop("entry_point", None)
return subparser
def print_help(self, file=None):
formatted_help = self.format_help()
formatted_help = formatted_help.replace("positional arguments:", "Positional Arguments:")
formatted_help = formatted_help.replace("optional arguments:", "Optional Arguments:")
print(formatted_help)
self.exit()
def get_parser(help_menu=False):
parser = SupercommandArgumentParser(
description=__doc__, formatter_class=argparse.RawTextHelpFormatter
)
version_string = "%(prog)s {version} ({python_impl} {python_version} {platform})".format(
version=__version__,
python_impl=platform.python_implementation(),
python_version=platform.python_version(),
platform=platform.platform(),
)
parser.add_argument("--version", action="version", version=version_string)
# Add help options for this parser
def halp(args):
"""Print help message"""
parser.print_help()
parser.add_parser_func(halp)
# Add subparser
states_cli.add_commands(parser._subparsers, help_menu=help_menu)
argcomplete.autocomplete(parser)
return parser
def main(args=None):
if not args:
args = sys.argv[1:]
if "--help" in args or "-h" in args:
parser = get_parser(help_menu=True)
else:
parser = get_parser()
if len(args) < 1:
parser.print_help()
parser.exit(1)
# Parse the arguments provided to main() function
parsed_args = parser.parse_args(args=args)
# Set logging levels for external libraries
logging.basicConfig(level=logging.ERROR)
try:
# Call the entry point function, passing the parsed arguments
result = parsed_args.entry_point(parsed_args)
except Exception as e:
# O noes!!1
err_msg = traceback.format_exc()
print(err_msg, file=sys.stderr)
exit(os.EX_SOFTWARE)
else:
if isinstance(result, SystemExit):
raise result
elif result is not None:
if isinstance(result, bytes):
sys.stdout.buffer.write(result)
else:
# The default arg here ensures smooth handling of datetime
print(json.dumps(result, indent=4, default=lambda x: str(x)))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment