Project Setup

Enforcing Import Boundaries in a Python CLI Codebase

Keep a Python CLI’s layers separate with import-linter: layers, forbidden and independence contracts, keeping heavy libraries out of core, and a startup-time guard.

Updated

Every guide to structuring a CLI recommends the same shape: a thin command layer on top, services in the middle, and core logic at the bottom that knows nothing about Typer, Rich or HTTP. The shape pays off — core logic is reusable from other programs, testable without a CLI runner, and fast to import — until someone adds from rich import print to a core module to debug something, or imports typer.BadParameter into a validator because it gives a nicer error. Each shortcut is small; together they turn the layers back into a tangle, and nobody notices until mytool --help takes 400 ms or the core package cannot be imported without the whole CLI. This guide turns the architecture into rules a machine checks, using import-linter, with contracts for layers, forbidden dependencies and independent commands, plus a startup-time guard. It is part of the linting and type-checking topic.

Prerequisites

  • A CLI package with some layered structure — for example mytool.cli, mytool.services, mytool.core — as described in how to structure a large Python CLI project.
  • import-linter as a dev dependency: uv add --dev import-linter.

The layers worth protecting

A typical CLI has three layers, and the rule is that imports point downward only:

Layers a CLI codebase should keep The layers of a CLI codebase from the command layer down to core logic, where each layer may import only the layers below it. Layers a CLI codebase should keep mytool.cli top Typer app, commands, output formatting mytool.services middle orchestration: api client, git wrapper, files mytool.core bottom pure logic and data models — no I/O, no typer, no rich Arrows point down only: core never imports cli, so core stays reusable and fast to test.
  • mytool.cli holds the Typer or Click app, command functions and output formatting. It may import anything.
  • mytool.services orchestrates: API clients, the git wrapper, file storage, credential lookup. It may import core, and the libraries it wraps, but not the CLI framework.
  • mytool.core holds domain logic and data models — pure Python that takes values and returns values. It imports nothing from the layers above and no heavy third-party libraries.

When core stays pure, it can be imported by a web service, a notebook or a test without dragging in a terminal UI; it can be unit-tested without mocks; and importing it costs almost nothing at startup, which matters because every command imports it.

The recipe: import-linter contracts

import-linter builds the import graph of your package statically and checks it against contracts declared in pyproject.toml:

# pyproject.toml
[tool.importlinter]
root_packages = ["mytool"]
include_external_packages = true

[[tool.importlinter.contracts]]
name = "Layered architecture"
type = "layers"
layers = [
  "mytool.cli",
  "mytool.services",
  "mytool.core",
]

[[tool.importlinter.contracts]]
name = "Core has no framework or heavy dependencies"
type = "forbidden"
source_modules = ["mytool.core"]
forbidden_modules = ["typer", "click", "rich", "httpx", "keyring"]

[[tool.importlinter.contracts]]
name = "Services do not depend on the CLI framework"
type = "forbidden"
source_modules = ["mytool.services"]
forbidden_modules = ["typer", "click", "rich"]

[[tool.importlinter.contracts]]
name = "Commands are independent"
type = "independence"
modules = [
  "mytool.cli.commands.deploy",
  "mytool.cli.commands.report",
  "mytool.cli.commands.auth",
]

Run it with uv run lint-imports. include_external_packages = true is what makes the forbidden contracts on third-party packages work; without it, import-linter only analyses imports within your own package.

Contract types in import-linter The three main import-linter contract types, what each enforces, and a CLI example of each. Contract types in import-linter Contract Enforces CLI example layers higher may import lower only cli → services → core forbidden these may not import those core may not import typer, rich, httpx independence siblings may not import each other commands.deploy vs commands.report Forbidding framework imports in core is the single most valuable contract for a CLI.

What each contract protects

Layers stop the most damaging kind of erosion: a lower layer reaching up. If mytool.core.plan imports mytool.cli.output for a formatting helper, core now depends on the CLI and everything it imports. The layers contract fails with the exact chain of imports responsible.

Forbidden (core) keeps heavy and framework libraries out of the code every command loads. This is the contract most CLIs benefit from, because it doubles as a startup-time guard: Rich, httpx and keyring each add tens of milliseconds of import time, and a single transitive import from core means paying that on every --help and every Tab press. The broader techniques are in CLI startup performance and lazy loading.

Forbidden (services) keeps presentation out of the middle layer. A service that raises typer.Exit or prints with Rich cannot be reused by anything but this CLI; one that raises its own exceptions and returns data can.

Independence stops commands from importing each other. When report imports a helper from deploy, the two are coupled, and lazy-loading one loads both. Shared helpers belong in services or core.

Reading a failure

A broken contract, reported Terminal output of lint-imports reporting that the core layer imports rich through a chain of modules. A broken contract, reported bash $ uv run lint-imports Core has no framework dependencies BROKEN mytool.core.plan -> mytool.core.display (l.4) mytool.core.display -> rich.table (l.2) Contracts: 2 kept, 1 broken. Indirect imports are reported with the full chain, which shows exactly where to cut.

import-linter reports indirect chains, which is what makes it more than a grep: here core.plan never imports Rich itself, but it imports core.display, which does. The fix is to move the display helper up into the CLI layer and have core return data instead. When a violation is known and cannot be fixed immediately, the contract can list it under ignore_imports — keep that list short and treat each entry as debt with an owner.

A test-suite fallback

If adding another tool is not an option, a small test can enforce the most important rule — "core does not import these packages" — by actually importing core in a clean interpreter and inspecting sys.modules:

# tests/test_boundaries.py
import json
import subprocess
import sys

FORBIDDEN = {"typer", "click", "rich", "httpx", "keyring"}


def test_core_imports_no_heavy_packages():
    code = (
        "import json, sys\n"
        "import mytool.core\n"
        "import pkgutil, importlib\n"
        "for m in pkgutil.walk_packages(mytool.core.__path__, 'mytool.core.'):\n"
        "    importlib.import_module(m.name)\n"
        "print(json.dumps(sorted({n.split('.')[0] for n in sys.modules})))\n"
    )
    out = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, check=True)
    loaded = set(json.loads(out.stdout))
    assert not (loaded & FORBIDDEN), f"core pulled in: {sorted(loaded & FORBIDDEN)}"

Running in a subprocess gives a clean sys.modules unaffected by whatever the test session already imported. This checks runtime behaviour rather than the static graph, which also catches imports made through importlib or plugins — a useful complement even when import-linter is in place.

Measuring the payoff

The same boundary shows up in startup time, and it is worth measuring once so the benefit is concrete:

$ uv run python -X importtime -c "import mytool.core" 2>&1 | tail -1
import time:       312 |       4870 | mytool.core
$ uv run python -X importtime -c "import mytool.cli" 2>&1 | tail -1
import time:      1204 |      61230 | mytool.cli

The cumulative column is in microseconds: importing core takes about 5 ms, importing the full CLI layer about 61 ms. Keeping core free of framework imports is what keeps that first number small. Profiling Python CLI startup time covers reading -X importtime output in detail.

UX considerations

For the developers working in the codebase:

  • Name contracts after the rule, not the tool. "Core has no framework dependencies" tells a contributor what they broke; "contract 2" does not.
  • Explain the why near the contract. A comment in pyproject.toml — "core is imported by every command; keep it light" — turns a failure into a lesson rather than an obstacle.
  • Start with one contract. The forbidden-imports contract on core is the highest value and least controversial. Add layers and independence once the structure they describe actually exists.
  • Run it where people will see it. In CI's lint job and as a pre-commit hook (lint-imports is fast enough), so violations are caught in the pull request that introduces them.

Testing the behaviour

To confirm the contracts work, break one deliberately:

echo "from rich import print  # temporary" >> src/mytool/core/plan.py
uv run lint-imports        # expect: "Core has no framework ..." BROKEN
git checkout src/mytool/core/plan.py

A contract that has never been seen to fail may be misconfigured — a typo in a module name, or include_external_packages missing so external imports are invisible. lint-imports --verbose prints the graph it built and the modules each contract considered, which helps diagnose both.

Conclusion

Architecture rules that live only in a document erode one convenient import at a time. import-linter turns them into contracts checked on every commit: layers so imports point downward, forbidden modules so core stays free of frameworks and heavy libraries, and independence so commands do not tangle together. The same boundaries keep startup fast and core reusable, and a small runtime test can back them up. Start with the forbidden contract on core; it pays for itself the first time it fails.

Frequently asked questions

Does import-linter see imports inside functions?

Yes. It analyses all import statements in each module, including those inside functions, which is what you want — a lazy import from core to Rich is still a dependency. TYPE_CHECKING-only imports can be excluded with the exclude_type_checking_imports option.

How do plugins fit into the layers?

Treat plugins as a separate top-level layer that may import your public API (usually mytool.core and a small mytool.plugin_api module) but not internals. A forbidden contract from plugin packages to mytool.cli expresses that. See versioning a plugin API.

What if two commands genuinely share code?

Move the shared code down into services or core, where both can import it without depending on each other. The independence contract exists precisely to force that move.

How do I introduce contracts into a codebase that already violates them?

Write the contract as you want it, run lint-imports, and copy each reported violation into the contract's ignore_imports list with a comment naming who will fix it. The contract then passes and immediately blocks any new violation, while the ignore list shrinks as the old ones are untangled. Deleting the last entry is a satisfying pull request.

Is this worth it for a small CLI?

For a single-file script, no. Once a CLI has more than a handful of commands or more than one maintainer, a single forbidden contract on core is five lines of configuration and prevents the slowest-to-fix kind of decay.