Architecture

Registering CLI Commands from Modules Automatically

Discover command modules in a package with pkgutil and mount them as Typer groups automatically, with naming rules, ordering, frozen-binary caveats and a test that nothing is missed.

Updated

A CLI with five command groups registers them by hand in one file and nobody minds. A CLI with forty, maintained by several teams, ends up with a cli.py that is mostly imports and add_typer calls — a merge-conflict magnet where forgetting one line means a finished command silently never appears. Automatic registration removes that file: every module dropped into a commands/ package that follows a simple convention becomes a command group, discovered at startup with the standard library's pkgutil. It is the internal cousin of a plugin system — same idea, but for code in your own package, with none of the trust concerns. This guide builds it, covers naming and ordering, explains the one environment where it breaks (frozen binaries), and compares it with the explicit-registry alternative. It belongs to the multi-command structure topic.

Prerequisites

The convention

Files become commands A commands package where each module defining an app becomes a command group, a COMMAND_NAME overrides the derived name, and underscore modules are skipped. Files become commands mytool/commands/ discovered with pkgutil users.py → mytool users db_tools.py → mytool db reports.py → mytool reports _helpers.py skipped: private each module defines app = typer.Typer(...) The whole contract fits in the package docstring.

Each module in mytool/commands/ that defines a module-level app = typer.Typer(...) becomes a subcommand group. The group's name is the module name with underscores turned into dashes, unless the module sets COMMAND_NAME. Modules whose names start with an underscore are private helpers and are skipped. That is the whole contract, and it fits in the package's docstring.

src/mytool/
├── cli.py
└── commands/
    ├── __init__.py        # """Every module here that defines `app` becomes a command group."""
    ├── users.py           # app = typer.Typer(help="Manage users.")        -> mytool users
    ├── db_tools.py        # app = ...; COMMAND_NAME = "db"                 -> mytool db
    └── _helpers.py        # skipped: private

The recipe

# src/mytool/cli.py
from __future__ import annotations

import importlib
import pkgutil

import typer

from mytool import commands


def discover_command_modules() -> list[str]:
    return sorted(m.name for m in pkgutil.iter_modules(commands.__path__)
                  if not m.name.startswith("_"))


def build_app() -> typer.Typer:
    app = typer.Typer(no_args_is_help=True)

    @app.callback()
    def main() -> None:
        """mytool."""

    for name in discover_command_modules():
        module = importlib.import_module(f"{commands.__name__}.{name}")
        sub = getattr(module, "app", None)
        if not isinstance(sub, typer.Typer):
            raise TypeError(f"{module.__name__} must define `app = typer.Typer(...)`")
        app.add_typer(sub, name=getattr(module, "COMMAND_NAME", name.replace("_", "-")))
    return app


app = build_app()

pkgutil.iter_modules(commands.__path__) lists the modules and subpackages directly inside the package without importing them — it reads directory listings (or a zip's index). Sorting makes the order deterministic, so help output and tests do not depend on filesystem ordering. A module that does not follow the convention is a programming error, so it fails loudly with a TypeError naming the module rather than being skipped silently — the opposite of the policy for third-party plugins in isolating plugin failures.

# src/mytool/commands/db_tools.py
import typer

app = typer.Typer(help="Database maintenance.")
COMMAND_NAME = "db"


@app.command()
def vacuum() -> None:
    """Reclaim space."""
    typer.echo("vacuumed")
Discovered commands in help Terminal session showing automatically registered command groups in the help output and running one of them. Discovered commands in help bash $ mytool --help | sed -n "/Commands/,$p" ╭─ Commands ──────────────────────────────╮ │ db Database maintenance. │ │ users Manage users. │ $ mytool db vacuum vacuumed Adding a command is adding a file — no central list to edit.

Ordering commands in help

Typer lists commands in registration order, so alphabetical discovery gives alphabetical help — usually fine. If some commands should come first, let modules declare a COMMAND_ORDER integer and sort by (order, name), or use Rich help panels (rich_help_panel=) to group commands under headings, which reads better than any ordering trick once there are more than a dozen.

Lazy loading

Discovery imports every command module at startup, which is cheap only if command modules keep heavy imports inside their functions. If they cannot, combine discovery with lazy loading: list module names at startup to build the help and command list, and import a module only when its command is invoked, using a lazy group as in lazy-loading subcommands for faster startup. The cost is that each group's one-line help must then be available without importing it — for example from a small metadata mapping.

Subpackages as nested groups

When one area grows its own family of commands — mytool cloud aws …, mytool cloud gcp … — a subpackage can apply the same convention one level down. pkgutil.iter_modules reports subpackages with ispkg=True; the subpackage's __init__.py defines its own app, discovers its own modules with the same helper, and is mounted like any other module:

def discover(package) -> list[tuple[str, typer.Typer]]:
    found = []
    for info in sorted(pkgutil.iter_modules(package.__path__), key=lambda i: i.name):
        if info.name.startswith("_"):
            continue
        module = importlib.import_module(f"{package.__name__}.{info.name}")
        found.append((getattr(module, "COMMAND_NAME", info.name.replace("_", "-")), module.app))
    return found

Each package then mounts what discover(...) returns onto its own app. Keeping the recursion explicit — each level calls the helper for its own children — means the command tree mirrors the package tree exactly, and a reader can always answer "where does mytool cloud aws come from?" by following directories.

Frozen binaries need help

PyInstaller and similar tools decide what to bundle by following import statements. Modules that are only ever imported by name through importlib.import_module are invisible to that analysis, so a frozen binary contains cli.py but not the command modules — and starts with no commands at all. Tell the bundler explicitly:

pyinstaller --onefile --collect-submodules mytool.commands -n mytool src/mytool/__main__.py

--collect-submodules bundles every module in the package, and PyInstaller's import machinery supports pkgutil.iter_modules inside the frozen app. Add a smoke test of the built binary that checks at least one auto-registered command appears in --help, as in bundling a Python CLI with PyInstaller.

Explicit registry: the alternative

Automatic discovery trades explicitness for convenience. The alternative keeps a single list:

COMMAND_MODULES = ["users", "db_tools", "reports"]

An explicit list works with every bundler and type checker, makes "where is this command registered?" a text search away, and lets you control order exactly. The weakness is forgetting to add a module — which a test fixes cheaply:

def test_every_command_module_is_registered():
    from mytool.cli import COMMAND_MODULES, discover_command_modules
    assert sorted(COMMAND_MODULES) == discover_command_modules()
Discovery or an explicit list? A comparison of automatic command discovery with an explicit registry list on convenience, bundling and visibility. Discovery or an explicit list? Property pkgutil discovery Explicit list + test Adding a command add a file add a file + a line Frozen binaries needs --collect-submodules works as is Find the registration know the convention text search Merge conflicts none occasional Large, multi-team CLIs favour discovery; small or frozen ones favour the list.

Choose discovery for large CLIs where many people add commands and the convention is easy to state; choose the explicit list with a guard test for smaller CLIs, or ones shipped as frozen binaries where every extra build flag is a liability.

UX considerations

The users of this mechanism are the developers adding commands:

  • Document the convention where they will look — the commands/__init__.py docstring and the contributing guide — in two sentences.
  • Fail loudly on violations. A module without app, or two modules mapping to the same command name, should break at startup with a message naming the files involved.
  • Keep helpers private by name. The underscore rule means shared helpers never accidentally become commands.
  • Do not discover recursively by default. One level keeps the mapping from files to commands obvious; nested groups can register their own children explicitly.

Testing the behaviour

# tests/test_registration.py
from typer.testing import CliRunner

from mytool.cli import build_app, discover_command_modules


def test_private_modules_are_skipped():
    assert "_helpers" not in discover_command_modules()


def test_names_come_from_modules_or_command_name():
    result = CliRunner().invoke(build_app(), ["--help"])
    assert "users" in result.output and " db " in result.output
    assert "db-tools" not in result.output


def test_auto_registered_command_runs():
    assert CliRunner().invoke(build_app(), ["db", "vacuum"]).output == "vacuumed\n"


def test_no_duplicate_command_names():
    import importlib

    from mytool import commands
    names = []
    for module_name in discover_command_modules():
        module = importlib.import_module(f"{commands.__name__}.{module_name}")
        names.append(getattr(module, "COMMAND_NAME", module_name.replace("_", "-")))
    assert len(names) == len(set(names)), f"duplicate command names: {names}"

The duplicate-name test covers the one conflict the convention allows: two modules that set the same COMMAND_NAME, or a module named db.py alongside db_tools.py with COMMAND_NAME = "db". Typer would let the later registration win; the test makes it an error.

Conclusion

Automatic registration turns "add a command" into "add a file": discover modules in a commands package with pkgutil.iter_modules, skip private ones, require each to define app, derive names from module names with an optional COMMAND_NAME, and sort for stable help. Bundle the package explicitly for frozen binaries, keep command modules light or load them lazily, and test for missing conventions and duplicate names. For smaller CLIs, an explicit list plus a guard test gives the same safety with no magic.

Frequently asked questions

Does this work with Click instead of Typer?

Yes: require each module to define a click.Group (or click.Command) named cli, and call root.add_command(module.cli, name) in the loop. With Typer apps built on vendored Click, check for the Typer object rather than click.Group, since isinstance checks against the click package fail for Typer objects.

Can command modules live in several packages?

pkgutil.iter_modules accepts several paths, and namespace packages let different distributions contribute modules to the same package. At that point you are building a plugin system, and entry points — as in discovering plugins with entry points — are the more explicit mechanism.

How do I keep shared options consistent across auto-registered groups?

Put shared option definitions in a helper module (with a leading underscore so it is not registered) and import them in each command module, as shown in sharing common options across commands.

Will my IDE still find where a command is defined?

Yes for the command itself — it is an ordinary function in an ordinary module. What becomes less visible is registration, which is why the convention must be documented and short.