# 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 submodule` vs 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 DEPTH` and `--focus MODULE --radius N`.
- `cycles`: iterative Tarjan SCCs, shortest cycle through every edge, a minimal covering set of cycles with `file:line`, and the cheapest edge to break.
- `check`: layers (with exceptions), forbidden imports, independence, external package allowlists; `--transitive` shows the shortest chain; exit code 1 on violations; `--format github` emits 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

```bash
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

```bash
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:

```yaml
- 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

```toml
[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](docs/algorithms.md) for the design in depth.

## Tests

```bash
.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 and `sys.path` tricks are invisible.
- `import a.b.c` produces one edge to the deepest module found, not to each parent package (`dead` does account for parents).
- Conditional imports other than `TYPE_CHECKING` (e.g. `try/except ImportError`, `sys.version_info`) count as ordinary imports.
- `check --transitive` applies 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.
