Architecture

Structuring a Large Python CLI Project

Use the src/ layout, namespace packages, and layered modules to structure large Python CLI projects for testability and long-term maintainability.

Updated

A CLI that started as a single main.py and grew to forty subcommands needs more than a tidy desk — it needs a layout that keeps import paths predictable, startup fast, and tests fast. This article gives a concrete, opinionated structure for a large Python CLI: the src/ layout, layered packages, namespace packages for plugins, and lazy command loading so a tool with dozens of commands still starts in milliseconds.

TL;DR

  • Use the src/ layout — your package lives under src/, not at the repo root — so tests run against the installed package and can't accidentally import from the working directory.
  • Organize by layer, not by feature dump: commands/ (thin parsing wrappers), core/ (business logic), io/ (formatting).
  • Lazy-load commands so importing the root group doesn't import every subcommand's dependencies — this is what keeps startup time flat as the command count grows.
  • Put tests in a top-level tests/ directory: fast unit tests against core/, CliRunner smoke tests against commands/.
  • Use namespace packages when you want third parties (or separate internal repos) to contribute subcommands.
src/ layout for a large CLI src/ layout for a large CLI Project tree src/ layout myapp/ pyproject.toml src/myapp/ __init__.py cli.py commands/ report.py sync.py core/ io/ tests/ commands/ — argument parsing & CLI glue core/ — business logic, framework-free io/ — output & formatting one importable package under src/ — tests run against the installed wheel

The src/ layout, and why

The single most important structural decision is putting your importable package inside a src/ directory rather than at the repository root:

my-cli/
├── pyproject.toml
├── src/
│   └── reporter/
│       ├── __init__.py
│       ├── cli.py
│       ├── commands/
│       ├── core/
│       └── io/
└── tests/
    ├── test_sales.py
    └── test_report.py

Without src/, the repository root is on sys.path whenever you run anything from it, so import reporter finds the source tree directly — even if you forgot to install the package, even if your packaging config is broken. Tests pass locally and fail in CI or for users. The src/ layout removes the root from the import path: the only way to import reporter is to install it (pip install -e .), which means your tests exercise the same thing your users get. It catches missing __init__.py files, files left out of the wheel, and MANIFEST gaps before release.

The matching pyproject.toml is short:

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "reporter"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = ["click>=8.1"]

[project.scripts]
reporter = "reporter.cli:cli"

[tool.hatch.build.targets.wheel]
packages = ["src/reporter"]

Organize by layer

Resist the urge to make one giant commands.py. Split responsibilities into packages so the import graph mirrors the architecture. The core/ service layer is pure Python with no framework imports — it's where the work happens:

Imports only ever point downwards A three layer architecture where the command layer imports the core layer, the core layer imports the input output layer, and no layer imports the one above it. Imports only ever point downwards may import everything below it commands/ click / typer live here Parses input, prints output, chooses an exit code. core/ plain Python The rules and calculations. Knows nothing about a terminal. io/ adapters HTTP clients, file readers, database sessions. imports nothing from the layers above One upward import is all it takes to make core/ untestable without a CLI runner.
# src/reporter/core/sales.py
from __future__ import annotations
from dataclasses import dataclass

@dataclass(frozen=True)
class SalesSummary:
    total: float
    count: int

    @property
    def average(self) -> float:
        return self.total / self.count if self.count else 0.0

def summarize(amounts: list[float]) -> SalesSummary:
    """Pure business logic: no I/O, no framework objects."""
    return SalesSummary(total=sum(amounts), count=len(amounts))

The io/ layer turns service results into output, and only this layer changes when you add --format json or a Rich table:

# src/reporter/io/render.py
from __future__ import annotations
from reporter.core.sales import SalesSummary

def render_summary(summary: SalesSummary) -> str:
    return f"count={summary.count} total={summary.total:.2f} avg={summary.average:.2f}"

The commands/ layer is thin glue — parse, delegate to core/, render with io/:

# src/reporter/commands/report.py
from __future__ import annotations
import click
from reporter.core.sales import summarize
from reporter.io.render import render_summary

@click.command(name="report")
@click.argument("amounts", nargs=-1, type=float)
def report_command(amounts: tuple[float, ...]) -> None:
    """Summarize AMOUNTS. Thin wrapper: parse -> service -> render."""
    summary = summarize(list(amounts))
    click.echo(render_summary(summary))

Where tests live

Tests sit in a top-level tests/ directory, outside src/, so they aren't packaged into the wheel. Mirror the layering: most tests target core/ as plain function calls, and a thin smoke test per command verifies the wiring. Both run in-process — no subprocess — so the suite stays fast even with dozens of commands.

The test tree mirrors the source tree A tests directory that mirrors the package: unit tests for core, command tests using the runner, and a smoke test for the installed entry point. The test tree mirrors the source tree tests/ one file per source module core/test_sync.py plain functions, fastest tests commands/test_sync.py CliRunner, flags and exit codes test_entry_point.py the installed script actually runs conftest.py shared fixtures and fake config Most assertions live in the core tests Command tests cover flags, not logic One smoke test proves packaging works When a file moves in src/, the matching test file moves with it — the mapping is mechanical.
# tests/test_report.py
from click.testing import CliRunner
from reporter.cli import cli
from reporter.core.sales import summarize

def test_service_pure() -> None:
    s = summarize([10.0, 20.0, 30.0])
    assert s.total == 60.0 and s.count == 3 and s.average == 20.0

def test_report_command() -> None:
    result = CliRunner().invoke(cli, ["report", "10", "20", "30"])
    assert result.exit_code == 0
    assert "count=3 total=60.00 avg=20.00" in result.output

Running this against an editable install:

$ pytest -q
..                                                                       [100%]
2 passed in 0.01s

Lazy command loading for startup time

Here's the problem that bites large CLIs: if cli.py imports every command module at the top, then any invocation — even mycli --help — imports the dependencies of every command. If one command imports pandas and another imports boto3, your --version flag now pays for both. Startup time creeps up as the command count grows.

The fix is a lazy Group that defers importing a command module until that command is actually invoked. The group knows the import path for each command as a string and resolves it on demand:

# src/reporter/cli.py
from __future__ import annotations
import importlib
import click

class LazyGroup(click.Group):
    """Defer importing command modules until a command is invoked."""
    def __init__(self, *args, lazy_subcommands=None, **kwargs):
        super().__init__(*args, **kwargs)
        self._lazy = lazy_subcommands or {}

    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_path, attr = self._lazy[name].rsplit(":", 1)
            mod = importlib.import_module(module_path)
            return getattr(mod, attr)
        return super().get_command(ctx, name)

@click.group(cls=LazyGroup, lazy_subcommands={
    "report": "reporter.commands.report:report_command",
})
def cli() -> None:
    """reporter: example layered CLI."""

list_commands makes report show up in --help without importing reporter.commands.report; get_command imports it only when the user actually runs reporter report. With dozens of commands, only the one being run — and its dependencies — gets loaded. The mapping is a plain dict, so it's also the natural place to register commands discovered from plugins.

Namespace packages and scaling to dozens of commands

When you want a command tree spread across multiple distributions — a core package plus optional plugin packages, or separate internal repos — reach for namespace packages (PEP 420). Multiple installed distributions can contribute modules under a shared package name like reporter.commands.* without any of them shipping an __init__.py for the namespace, and without one "owning" the directory. Combined with entry-point discovery, that lets a plugin register its command into the lazy mapping at install time. The deep dive on this lives in plugin architectures for extensible CLIs.

At the scale of dozens of commands, three habits keep the project healthy: one module per command under commands/ (never a grab-bag file), all real logic in core/ where it's unit-testable without the framework, and lazy loading so the import cost of a rarely-used command never slows the common path. The directory shape stays identical from five commands to fifty — contributors always know exactly where a new command, its logic, and its tests belong.

Frequently asked questions

Is the src layout worth the extra directory?

Yes, for anything you package. Without src, the package directory sits next to the tests and Python will import it straight from the working directory — which means your tests exercise the source tree rather than the installed artifact, and a packaging mistake stays invisible until a user hits it. The src layout makes the import path go through the install, so what you test is what you ship.

How do I decide whether code belongs in commands/ or core/?

Ask whether it would still make sense if the tool grew a web API. Reading a flag, formatting a table and choosing an exit code are CLI concerns and belong in commands/. Deciding what to sync, computing a diff, or talking to a service is domain work and belongs in core/, where it can be tested without a runner and reused by anything.

How many commands is too many for one module?

One module per command is the layout that stops the question coming up. It keeps each file small enough to hold in your head, makes lazy loading trivial to add later, and turns a merge conflict over a shared commands.py into two independent files. The cost is a registration line per command, which is cheap.

Where do shared options like --config live?

On the group callback, not repeated on every command. Declaring them once means one place to change the help text and one place that builds the settings object, and commands receive the result through the context rather than re-reading the environment. If two commands need an option that others do not, a small shared decorator is better than duplicating the declaration.

Should tests mirror the source tree exactly?

It is the cheapest convention that keeps working at scale. When tests/core/test_sync.py sits opposite src/mytool/core/sync.py, finding the tests for a file is mechanical, and a moved module drags its tests along in the same commit. The alternative — organising tests by kind — makes coverage gaps much harder to see.

Where do shared helpers live once there are several of them?

Give them a home that says what they are for rather than what they are — mytool/core/paths.py rather than mytool/utils.py. A utils module is where unrelated functions accumulate until nobody can tell what depends on what, and it is invariably the module with the widest import graph. If a helper is only used by one command, keep it in that command's module until a second caller appears.