importlens
importlens builds the module import graph of a Python codebase from source (AST only, nothing is executed). It finds import cycles and suggests the cheapest edge to break, enforces layer and contract rules in CI, reports coupling metrics, finds modules nobody reaches, diffs the architecture against a git ref, and writes a self-contained interactive HTML report.
Features
- Graph builder: absolute and relative imports,
from pkg import submodulevs attribute resolved by checking which module files exist,__init__and namespace packages,TYPE_CHECKING-guarded imports (flagged, optionally excluded), imports inside functions flagged as lazy, line numbers for every import site, parse cache keyed by mtime+size+sha1. graph: DOT, Mermaid or JSON export, with--collapse DEPTHand--focus MODULE --radius N.cycles: iterative Tarjan SCCs, shortest cycle through every edge, a minimal covering set of cycles withfile:line, and the cheapest edge to break.check: layers (with exceptions), forbidden imports, independence, external package allowlists;--transitiveshows the shortest chain; exit code 1 on violations;--format githubemits workflow annotations.metrics: fan-in, fan-out, instability, abstractness, distance from the main sequence, LOC, per module and per package; sortable table or--json.dead: modules unreachable from entry points (--entry,[project.scripts],__main__modules,__all__re-exports).diff: new/removed edges, new cycles and newly introduced contract violations versus a git ref.html: one HTML file, no CDN, own force-directed layout, search, click-to-highlight neighbours, cycle highlighting.
Install
uv venv .venv && uv pip install -p .venv/bin/python -e '.[dev]'
.venv/bin/importlens --help
Requires Python 3.11+. Runtime dependencies: click, rich.
Quickstart
cd examples
../.venv/bin/importlens cycles sample_project
../.venv/bin/importlens check sample_project --config importlens.toml
../.venv/bin/importlens html sample_project -o report.html
SRC is either a source root (containing top-level packages) or a package directory itself; module names are always relative to the directory above the outermost enclosing package.
Shared options for every command: --exclude-type-checking, --exclude-lazy, --cache-dir (default .importlens_cache), --no-cache.
Usage
cycles
$ importlens cycles sample_project
Cycle group 1: 3 modules, 1 representative cycle(s)
sample_project.domain.order, sample_project.domain.policies, sample_project.domain.pricing
cycle 1 (length 3)
sample_project.domain.order -> sample_project.domain.pricing sample_project/domain/order.py:3
sample_project.domain.pricing -> sample_project.domain.policies sample_project/domain/pricing.py:2
sample_project.domain.policies -> sample_project.domain.order sample_project/domain/policies.py:2
cheapest edge to break: sample_project.domain.order -> sample_project.domain.pricing (on 1 of 1 shortest cycles, 1 import site(s))
sample_project/domain/order.py:3
Cycle group 2: 2 modules, 1 representative cycle(s)
sample_project.services.billing, sample_project.services.order_service
...
Options: --json, --fail-on-cycles (exit 1 if any cycle).
check
$ importlens check sample_project --transitive
layers (direct): layer 'sample_project.infra' must not import higher layer 'sample_project.services'
sample_project.infra.mailer -> sample_project.services.notifications sample_project/infra/mailer.py:4
layers (indirect): layer 'sample_project.infra' must not import higher layer 'sample_project.domain'
sample_project.infra.mailer -> sample_project.services.notifications -> sample_project.domain.user sample_project/infra/mailer.py:4
...
3 violation(s)
$ importlens check sample_project --format github
::error file=sample_project/infra/mailer.py,line=4,title=importlens layers::layer 'sample_project.infra' must not import higher layer 'sample_project.services'
--config defaults to ./importlens.toml. Formats: text, github, json. Exit code 1 when there is any violation. GitHub Actions step:
- run: importlens check src --config importlens.toml --format github
metrics
$ importlens metrics sample_project --level package --sort distance
Name Kind Ca Ce I A D LOC
sample_project.utils package 0 0 0.00 0.00 1.00 5
sample_project.domain package 9 0 0.00 0.17 0.83 54
sample_project.infra package 7 1 0.12 0.33 0.54 53
sample_project.services package 5 9 0.64 0.00 0.36 37
sample_project.api package 1 9 0.90 0.00 0.10 23
--level module|package|both, --sort name|fan_in|fan_out|instability|abstractness|distance|loc, --asc, --top N, --json.
dead
$ importlens dead sample_project
entry points: sample_project.cli, sample_project.__main__
6 unreachable of 42 modules
sample_project.infra.mailer
sample_project.infra.sql_repository
sample_project.services.legacy_export
sample_project.utils / .utils.dates / .utils.text
Entry points come from --entry (repeatable), [project.scripts]/[project.gui-scripts] in the nearest pyproject.toml above SRC, and __main__ modules. --json, --fail-on-dead.
diff
$ importlens diff sample_project --base main --config importlens.toml
importlens diff main -> working tree: 3 new edge(s), 0 removed, 0 new module(s), 0 removed
+ edge sample_project.domain.money -> sample_project.api.router sample_project/domain/money.py:12
+ edge sample_project.domain.product -> sample_project.services.billing sample_project/domain/product.py:11
new cycle: sample_project.domain.order -> sample_project.domain.product -> sample_project.services.billing -> sample_project.domain.order
new violation layers: sample_project.domain.money -> sample_project.api.router sample_project/domain/money.py:12
The base graph is built from a git archive export of the ref into a temp directory, so the working tree is never touched. Exit 1 on new cycles or new violations (--config optional).
graph
$ importlens graph sample_project --focus sample_project.services.billing --radius 1 --format mermaid
graph LR
n0["sample_project.domain.order"]
n1["sample_project.services.billing"]
n2["sample_project.services.order_service"]
n1 --> n0
n1 --> n2
n2 --> n0
n2 --> n1
linkStyle 1,3 stroke:#d33,stroke-width:2px
Cycle edges are red, TYPE_CHECKING edges dashed, lazy edges dotted. -o FILE writes to a file.
html
importlens html SRC -o report.html [--collapse DEPTH]. Scroll to zoom, drag to pan or move nodes, click a module for its imports and importers with line numbers, search with Enter to jump, "Highlight cycles" lists every cycle. URL hash shortcuts: #cycles, #q=text, #select=module.name. For very large codebases use --collapse 3 to keep the picture readable.
Contract configuration
[layers] # highest first; a layer may import itself and lower layers
order = ["app.api", "app.services", "app.domain", "app.infra"]
allow = [{ from = "app.infra.compat", to = "app.domain.types" }]
[[forbidden]]
name = "api-not-sql"
from = "app.api"
to = "app.infra.sql_repository"
message = "go through services" # optional
[[independence]]
modules = ["app.api.users", "app.api.orders"]
[[external]] # third-party packages (stdlib is always allowed)
module = "app.domain"
allow = []
Patterns are globs (fnmatch) and also cover everything below a named module (app.api matches app.api.users). examples/importlens.toml is a complete example and importlens.toml at the repo root is importlens checking itself.
Results on real code
Measured on Python 3.12, one machine, --no-cache unless noted:
| Target | Size | Command | Time |
|---|---|---|---|
| importlens itself | 14 modules | cycles / check |
0.06 s / 0.07 s |
| Django 5 (pip-installed) | 884 modules, 3142 edges | graph |
0.9 s |
cycles |
1.8 s | ||
metrics |
1.3 s | ||
graph with warm cache |
0.2 s |
Django has 16 cycle groups; the biggest has 171 modules. Most of that is package __init__ re-exports and lazy imports, so --exclude-lazy is a useful first filter.
Architecture
| Module | Role |
|---|---|
parser.py |
AST walk for one file: imports (level, names, line, TYPE_CHECKING, lazy), classes, __all__, LOC |
cache.py |
mtime+size / sha1 parse cache in .importlens_cache/parsed.json |
builder.py |
module discovery, import resolution, Graph construction |
model.py |
Graph, Module, Edge, Site dataclasses |
algorithms.py |
Tarjan SCC, BFS, cycle analysis, collapse, focus |
contracts.py |
config loading and rule evaluation |
metrics.py, dead.py, diff.py |
one per command |
exporters.py, html_report.py, assets/ |
DOT/Mermaid/JSON and the HTML report (layout.js is the pure layout, app.js the UI) |
cli.py |
click commands and rich output |
See docs/algorithms.md for the design in depth.
Tests
.venv/bin/pytest -q # 63 tests; the JS layout tests need node and are skipped without it
Limitations
- Static only:
importlib.import_module,__import__with computed names andsys.pathtricks are invisible. import a.b.cproduces one edge to the deepest module found, not to each parent package (deaddoes account for parents).- Conditional imports other than
TYPE_CHECKING(e.g.try/except ImportError,sys.version_info) count as ordinary imports. check --transitiveapplies to layers, forbidden and independence rules, not to external allowlists.- Metrics are module-based: Ca/Ce count distinct modules, not classes, and third-party imports are not counted in Ce.
- Instability of an isolated module is defined as 0.
- Files with syntax errors are skipped with a warning.