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-linteras 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:
mytool.cliholds the Typer or Click app, command functions and output formatting. It may import anything.mytool.servicesorchestrates: API clients, the git wrapper, file storage, credential lookup. It may importcore, and the libraries it wraps, but not the CLI framework.mytool.coreholds 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.
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
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-importsis 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.
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.