Custom groups and lazy loading

Click · Expert · 13 min

Large CLIs need fast startup and extensibility, which come from controlling how commands are found.

A click.Group finds subcommands through two methods:

  • list_commands(ctx) returns the names shown in help and completion,
  • get_command(ctx, name) returns the Command for a name, or None.

The default implementation reads a dict of registered commands. Overriding these methods changes discovery without touching the rest of Click.

Lazy loading: map names to import paths ("deploy": "mytool.commands.deploy:cli") and import the module only in get_command. Then mytool --help and mytool status no longer import the heavy dependencies of every other command, which can cut startup from seconds to milliseconds. Note that --help on the group calls get_command for every listed command to fetch short help, so keep command modules themselves light, or provide summaries separately.

Plugins: discover commands from installed packages via importlib.metadata.entry_points(group="mytool.plugins") in list_commands/get_command.

Aliases and prefixes: get_command can resolve ci to commit, or accept unique prefixes, by matching against list_commands.

Use a custom class with @click.group(cls=LazyGroup, ...); extra keyword arguments go to its __init__. Other override points include format_commands for help output, resolve_command for parsing, and Command.invoke or Context subclasses (context_class) for cross-cutting behaviour such as timing every command.

Example

import importlib
import click

class LazyGroup(click.Group):
    def __init__(self, *args, lazy=None, **kwargs):
        super().__init__(*args, **kwargs)
        self.lazy = lazy or {}                    # name -> "module:attr"

    def list_commands(self, ctx):
        return sorted([*super().list_commands(ctx), *self.lazy])

    def get_command(self, ctx, name):
        if name in self.lazy:
            module, attr = self.lazy[name].split(":")
            return getattr(importlib.import_module(module), attr)  # imported on demand
        matches = [c for c in self.list_commands(ctx) if c.startswith(name)]
        return super().get_command(ctx, matches[0]) if len(matches) == 1 else None
@click.group(cls=LazyGroup, lazy={"pretty": "json:tool"})   # demo target: any importable object
def cli():
    pass
@cli.command()
def status():
    click.echo("all good")
print(cli.list_commands(None))                   # ['pretty', 'status']
cli.main(["stat"], standalone_mode=False)          # all good  (unique prefix)

Remember

  • Override list_commands and get_command to control discovery
  • Lazy loading imports a subcommand's module only when it is used
  • Plugins, aliases and prefix matching are all get_command logic

Watch out for

  • Making the lazily loaded modules themselves import everything, which defeats the purpose.

Check yourself

  1. Which Group method must return None for an unknown subcommand name?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: click.palletsprojects.com/en/stable/extending-click/