Metadata-Version: 2.5
Name: arch-blueprint
Version: 0.3.0
Summary: Draw architecture diagrams (PlantUML / D2) from a Python project's import graph
Project-URL: Homepage, https://github.com/nkhitrov/arch-blueprint
Project-URL: Repository, https://github.com/nkhitrov/arch-blueprint
Author-email: Nick Khitrov <khitrov34@gmail.com>
License-Expression: MIT
License-File: LICENSE
Keywords: architecture,diagrams,graphs,plantuml
Classifier: Topic :: Multimedia :: Graphics
Classifier: Topic :: Utilities
Requires-Python: >=3.9
Requires-Dist: grimp>=3.13
Requires-Dist: libcst>=1.8.4
Requires-Dist: networkx>=3.2
Description-Content-Type: text/markdown

# arch-blueprint

Explore the architecture of a Python project in the browser: its import graph, live, folded down
to the packages you care about and opened as deep as you need. The same graph is drawn as
[PlantUML](https://plantuml.com) or [D2](https://d2lang.com) source or a PNG, for docs and CI, and
compared across a branch or the project's history.

![arch-blueprint serve on the example project examples/shop](docs/images/serve.gif)

*The example project [`examples/shop`](examples/README.md#shop), opened with
`arch-blueprint serve examples/shop`.*

- **Modules, or classes and functions.** Each box is a module, an arrow an import between them.
  Switch to classes & functions to see which definition uses which.
- **Fold and open anything, independently.** Every package — and, at the class level, every
  module — folds into one box or opens, on its own: one package opened down to its classes while
  its neighbours stay single boxes. A folded box counts all the imports inside it.
- **Follow a dependency.** Click a box for what it depends on and what depends on it, an arrow
  for the imports behind it. A red arrow is a **cycle**.
- **Keep what you found.** The view lives in the page's address; "⧉ CLI command" copies the
  `draw` command that renders it as a diagram.

- [Quick start](#quick-start)
- [Explore in the browser](#serve): `serve`
- [Diagrams as text and images](#draw): [`draw`](#draw), [`diff`](#diff),
  [`history`](#history)
- [Choosing what to draw](#choosing-what-to-draw)
- [Metrics](#metrics)
- [Troubleshooting](#troubleshooting)
- [Examples](#examples)

## Quick start

```shell
pip install arch-blueprint
```

Point it at the directory **your packages sit in**: `src` for a src layout, otherwise usually the
repository root.

```shell
arch-blueprint serve src                # explore it in the browser
```

From a checkout of this repository, `arch-blueprint serve examples/shop` opens the example above.

The same graph as a diagram:

```shell
arch-blueprint draw src                 # PlantUML source on stdout
arch-blueprint draw src -o arch.puml    # the same, into a file
arch-blueprint draw src -o arch.png     # an image (needs `plantuml` on PATH)
arch-blueprint draw src -o arch.d2      # D2 source
```

![Example project graph](docs/images/example.png)

In a diagram, arrows are drawn between packages, at the level where the two modules' paths
part (`app2.service` → `app1.models` is drawn as `app2 → app1`); boxes can be classes and
functions too ([`--links class`](#arrows-between-classes-and-functions)).

To view a `.puml` without installing anything, paste it into the
[PlantUML web server](https://www.plantuml.com/plantuml). For `.d2` there's the
[D2 playground](https://play.d2lang.com). Or install the tool once (`brew install plantuml` /
`brew install d2`) and ask for a `.png` directly.

With no `-m`, every package in the directory is drawn whole, and the tool says which ones it
picked:

```
$ arch-blueprint draw examples/project_root -o example.png
arch-blueprint: drawing app1, app2, plugins (all modules; narrow with -m)
```

`arch-blueprint --help` lists the commands, and `arch-blueprint COMMAND --help` shows each
command's options with examples.

## Choosing what to draw

`-m PATTERN` chooses the modules. Repeat it to draw several packages together, with the links
between them:

| Pattern | Selects |
| --- | --- |
| `myapp.*` | the modules and subpackages directly inside `myapp` |
| `myapp.**` | everything under `myapp`, at any depth |
| `myapp.*.models` | `models` in every direct subpackage of `myapp` |
| `myapp` | `myapp` itself, drawn as **one box** (the tool warns you) |

```shell
arch-blueprint draw src -m 'myapp.*'
arch-blueprint draw . -m 'app1.*' -m 'app2.*' -m 'plugins.**'
```

Quote the patterns, or the shell expands the `*` itself.

A link is drawn only when both of its ends are selected. That includes a dependency *on* a
package whose children you selected: `pkg.*` never selects `pkg` itself, but an import of `pkg`
still counts.

### Everything next to the selection

`--deps out` turns the selection into the **focus** and draws what it depends on too, wherever in
`PROJECT_DIR` that lives — another package, another top-level package — without adding it to
`-m`. `--deps in` draws the other side: what depends on the focus. Either way such a neighbor is
drawn greyed out and dashed, under its full name, with every arrow between it and the focus, in
both directions — a dependency that imports the focus back is drawn as the cycle it is. What the
neighbors import from one another or from anything else is left out, and so are the standard
library and third-party packages. It works at every `--links` level: the imported module (or its
namespace), or the class or function a name refers to, followed through re-exports. At the class
levels `--deps in` also parses every module importing the focus, directly or through others.

```shell
arch-blueprint draw src -m 'app.features.core.executory_processes.**' --deps out --links class
arch-blueprint draw src -m 'app.features.core.executory_processes.**' --deps in --links class
```

`diff --base` and `history` take `--deps` too; a snapshot records it, so two snapshots built with
and without it can't be diffed. Metrics are not drawn with `--deps` (`--metric` is refused): the
neighbors' own imports are not read, so their numbers would be wrong.

## Commands

| Command | What it does |
| --- | --- |
| `serve PROJECT_DIR` | opens a page in the browser to explore the graph interactively |
| `draw PROJECT_DIR` | draws the project's import graph |
| `draw SNAPSHOT` | draws a snapshot saved earlier by `draw -o graph.json` |
| `diff` | draws what changed between two snapshots or two git revisions |
| `history PROJECT_DIR` | writes one picture per commit that changed the graph |

Output goes to stdout unless `-o FILE` is given. Without `-f`, the extension of `-o` picks the
format:

| `-f` | `-o` extension | Output |
| --- | --- | --- |
| `puml` (default) | `.puml` | PlantUML source |
| `d2` | `.d2` | D2 source |
| `puml-png` | `.png` | image drawn by `plantuml` from `PATH` |
| `d2-png` | — | image drawn by `d2` from `PATH` |
| `json` (`draw` only) | `.json` | a snapshot, to draw or diff later |

A missing `plantuml` / `d2` is reported before any work starts.

### serve

`serve` opens a page in the browser where the graph is explored instead of chosen up front: no
`-m` or `--links` to get right before you see anything.

```bash
arch-blueprint serve src                     # every package in src/
arch-blueprint serve src -m 'myapp.**'       # one package
arch-blueprint serve src --port 8000 --no-open
```

- **Modules, or classes and functions.** "boxes" switches what a box is: a module, or a class or
  module-level function, with arrows wherever one's code uses another.
- **Fold and unfold, each on its own.** The page opens on each package's direct subpackages,
  folded into one box each. Double-click a folded box to unfold it, or a frame to fold it back.
  Every package folds independently of the others, and at the class level so does every module:
  one package can be open down to its classes while its neighbours stay single boxes. A folded
  box draws what selecting that package whole (`-m pkg`) draws: its fan-in, fan-out and arrows
  count everything inside it.
- **Packages and modules.** A folded package is a square box with a double border, a 📁 and the
  number of modules it holds; an unfolded one is a 📂 frame; a module is a plain rounded box.
- **Show only one package.** "show only this" (in the details panel, or "only" beside a package
  in the tree, on hover) narrows the graph to that package's contents — its modules and
  subpackages, nothing around it. The browser's Back button, or "show all", returns.
- **Focus.** Click a box: what it depends on and what depends on it stay lit, the rest fades. The
  panel on the right lists both, with the box's metrics. Esc clears the focus.
- **Arrows.** Click an arrow to list the imports behind it. A red two-headed arrow is a cycle.
- **Large views.** Switching to classes & functions keeps the folds, so definitions appear only
  where you opened. A view of more than 100 boxes is not drawn until you confirm: it is past
  reading, and laying it out can freeze the page.
- **Find.** Type in the search box to highlight matching boxes. Enter goes to the first match,
  unfolding whatever package hides it.
- **Choose.** The checkboxes on the left show what is drawn: a box on the graph is ticked, a
  module hidden inside a folded box is not (tick it to unfold down to it), and an opened package
  is half-ticked when only part of it is drawn. "all" and "none" tick or clear every box. Turn
  "auto-redraw" off to tick a few boxes without a redraw after each one, then press "Redraw". A
  package you tick is drawn as one box (double-click it to open it) — unless something inside it
  is already shown: then it opens one level, keeping that in view. "metric" shows a metric on
  every box or arrow.
- **Share and keep.** The view lives in the page's address, so a link opens the same view.
  "⧉ CLI command" copies the `draw` command for the same modules, arrows and metric, ready for a
  doc or CI. `draw` folds nothing, so a view with folded packages notes that the command draws them
  unfolded.

The server listens on `127.0.0.1` only and stops with Ctrl-C. The import graph is read once per
selection and kept while the server runs: restart `serve` to see edits to the code.

### draw

```shell
arch-blueprint draw src                                   # every package in src/
arch-blueprint draw src -m 'myapp.*' -o arch.png          # one package, as an image
arch-blueprint draw src --metric fan_in --metric fan_out  # with metrics on the boxes
arch-blueprint draw src --cycle-details                   # with the imports behind each cycle
```

A cycle is drawn as a red double arrow. `--cycle-details` adds a note listing the imports that
make it up, so you can see which ones to break. It is off by default in every command, because on a
whole diagram the notes take up most of the picture:

```shell
arch-blueprint draw tests/fixtures/cyclic -m 'pkg_a.*' -m 'pkg_b.*' --cycle-details
```

```puml
pkg_a <-[#C0392B,bold]-> pkg_b
note on link
  **pkg_a -> pkg_b:**
  - core → util
  - services → util
  **pkg_b -> pkg_a:**
  - util → core
end note
```

A cycle isn't only a pair importing each other. A ring `a → b → c → a` of any length is found too
and its arrows are drawn red; with `--cycle-details` it gets one note listing every import on it,
tied to each of its modules. A pair inside such a ring gets no note of its own, so each import is
listed once. A cycle can also run through a package's `__init__.py`: `api.handlers` imports
`services`, whose `__init__.py` imports `services.engine`, which imports `api.handlers`. That
`__init__.py` import closes the cycle but isn't drawn (every package's re-exports would bury the
diagram); the note lists it as "package facade import, not drawn".

A namespace that is itself a module (`writer` importing `storage.backend`) stays a plain box.
Packages without an `__init__.py` (PEP 420 namespace packages) work as well.

#### Arrows between packages or between modules

By default an arrow joins the packages where two modules' paths part: `app2.service` importing
`app1.models` is drawn `app2 ---> app1`, with each module inside its package's frame (a chain of
frames that hold nothing but one frame is merged into one, `app.features.core`). `--links
module` draws it module to module instead:

```shell
arch-blueprint draw examples/project_root -m 'app1.*' -m 'app2.*' -m 'plugins.**' --links module
```

```puml
app2.service ---> app1.models
app2.service ---> plugins.auth.backend
```

Every arrow then ends on a box, so the boxes are drawn flat, without package frames, each labelled
by its dotted name less the prefix all boxes share, which is shown once as the diagram title. An import that lands inside a selected package ends on that package's box; an import
of a package itself (its `__init__.py`) gets a box of its own. Cycles, `edge_weight` and `diff` then
work between modules. `--links` is given to the commands that build a graph: `draw PROJECT_DIR`,
`diff --base` and `history`.

#### Arrows between classes and functions

`--links class` makes every top-level class and module-level function of the selected modules a box,
and draws an arrow from each to every other one its code names, anywhere in it: bases, metaclass,
decorators and class-level annotations, `__init__`, method signatures and method bodies, a
function's parameters, their defaults, return annotation and body, string (forward) annotations and
`cast("Foo", x)` included. Imports are followed through aliases, relative imports, imports inside a
function and re-exports in a package's `__init__.py`, and a module-level variable through its value
(`Alias = Foo | Bar`, `T = TypeVar("T", bound=Foo)`, `HANDLERS = {"a": Foo}`,
`settings = Settings()`); a parameter, local or match capture that happens to share a class's name
isn't that class. Functions are boxes of their own, so classes that only a function ties together
are linked through it:

```shell
arch-blueprint draw examples/project_root -m 'app1.*' -m 'app2.*' -m 'plugins.**' --links class
```

```puml
app2.service.make_user ---> app1.models.User
app2.service.make_user ---> plugins.auth.backend.AuthBackend
```

`class` draws the boxes flat under their dotted names, less the prefix every box shares (shown
once, as the title); `--links class-grouped` draws each box inside the frame of its module
instead, a chain of frames that hold nothing but one frame merged into one (`app.features.core`).
Cycles, metrics, snapshots, `diff` and `history` work between classes and functions as they do
between modules. A pair of boxes is one import however many times one names the other, so
`edge_weight` is always 1. Nothing is imported or run — the source is read with
[libcst](https://github.com/Instagram/LibCST), which takes some ten times longer than drawing
modules.

#### Snapshots

`draw -o graph.json` (or `-f json`) saves a **snapshot** instead of a picture: the modules, the
imports between them and every metric. Give `draw` the snapshot instead of a directory and it draws
it, in any format and with any metrics, byte for byte as it would have drawn the project. The
project isn't needed any more, and `diff` compares two snapshots:

```shell
arch-blueprint draw src -m 'myapp.*' -o graph.json
arch-blueprint draw graph.json -o graph.png --metric instability
```

A snapshot records its `--links` level and `--deps`, so drawing or diffing one takes neither, and
two snapshots built at different levels (or with and without `--deps`) can't be diffed.

### diff

`diff` draws the graph with the changes marked on it. It can compare two snapshots:

```shell
arch-blueprint diff old.json new.json -o diff.png
```

Or, when the project is in git, a revision against the working tree (or against `--head REV`). No
stored files are needed:

```shell
arch-blueprint diff --base origin/main src -o diff.png
arch-blueprint diff --base v1.0 --head v2.0 src -m 'myapp.*'
```

![Diff: a module and link added, a module and link removed](docs/images/diff.png)

Unchanged parts are drawn as on a plain diagram. Every change is dashed and has a color and a
label of its own:

| Marker | Module | Dependency |
| --- | --- | --- |
| added | green, spot `+`, `«added»`, dashed frame | green dashed arrow, `added` |
| removed | red, spot `-`, `«removed»`, dashed frame | red dashed arrow, `removed` |
| new cycle | — | red dashed `<->`, `NEW CYCLE` |
| cycle resolved | — | grey dashed arrow, `cycle resolved`, in the direction that remains (a bare line if neither does) |

A longer cycle is marked on its arrows the same way: red dashed `NEW CYCLE` on the unchanged arrows
of a new one (the import that closed it is `added`), grey dashed `cycle resolved` on what's left of
one that went.

- `--changes-only` draws just the changes and the modules they touch. That helps on a large
  project.
- `--cycle-details` adds the notes that list the imports of each new cycle.
- A package that exists on one side only is shown as added or removed; it doesn't fail the run.
- A module replaced by a package of the same name (`api.py` → `api/`) is drawn inside that package
  (with `--links module`, beside it, as `api (module)`).
- When nothing changed, you still get a valid diagram, with "No architectural changes" in the
  legend.
- `--metric NAME` draws a [metric](#metrics) as a plain diagram does, and shows how the changes
  moved it (see [Metrics in a diff](#metrics-in-a-diff)).

`diff` exits like `diff(1)`: **0** when nothing changed, **1** when something did, **2** on an
error. With `--metric`, a shown metric whose value changed counts as a change, even when the
structure didn't; without it, only structure counts. The diagram is written either way. In CI, to
fail on errors but not on changes:

```shell
arch-blueprint diff --base origin/main src -o diff.png || test $? -eq 1
```

### history

`history` walks the branch's first-parent history (one commit per merged pull request) and draws
one picture per commit that changed the graph. The first frame is the plain diagram. Every later
frame is the same diagram with what changed since the previous frame marked on it, like `diff`
(`--changes-only` for just the changes). Everything is declared in the plain diagram's order, so
consecutive frames lay out alike. Commits that leave the graph alone are skipped.

With `--metric`, every frame shows the metrics, each changed value as `old → new` like
[`diff`](#metrics-in-a-diff). A commit that moves a shown metric is then a frame too, even with no
structural change: one more import inside an existing arrow changes `edge_weight`, so the last
frame's values are the ones at `--head`. Without `--metric`, only structure makes a frame.

```shell
arch-blueprint history src                                         # every package in src/
arch-blueprint history src myapp -f puml-png                       # one package, as images
arch-blueprint history src app1 app2 -m 'app1.*' -m 'app2.core.*'  # packages narrowed by -m
arch-blueprint history src myapp --base v1.0 --head main -o album
```

```
blueprint-history/               (or -o DIR)
  0001_2026-05-02_ab12cd3.png    the first frame: the plain diagram
  0002_2026-05-12_ef45ab6.png    the diagram at that commit, its changes marked
  index.md                       the frames in order, with dates and commit subjects
```

- **ROOTs.** The packages named after `PROJECT_DIR` are the ones drawn, each whole unless `-m`
  narrows it. With none named, it's every package the project has at `--head`. A package that a
  commit doesn't have yet is no error: the commit is reported as `no source yet`, and the package
  shows up in the first frame that has its code.
- **One kind of file.** An album holds either sources (`-f puml`, `-f d2`) or images
  (`-f puml-png`, `-f d2-png`).
- **Caching.** Snapshots and images are cached in `./.arch-blueprint` (`--cache-dir`). After a
  failed image, rerunning redraws only what is missing, and another album of the same history
  reuses the cache.
- **Large diagrams.** d2 refuses to rasterize very large diagrams. Such a diagram is redrawn at
  half the scale, then at half again; `--scale FACTOR` sets the starting scale. PlantUML's size
  limit is raised to 16384 px (`PLANTUML_LIMIT_SIZE`; a value you set yourself wins).
- **Reruns.** Unchanged files are not rewritten. Frame files from an earlier run that this run
  didn't produce are removed; nothing else in the directory is touched.
- **Broken commits.** A commit whose code can't be analyzed is reported as `skipped`, with the
  reason.

## Metrics

`--metric NAME` shows a metric; repeat it for more. `arch-blueprint --list-metrics` prints this
list:

| Metric | On | Meaning |
| --- | --- | --- |
| `fan_in` | each box | how many modules depend on this one |
| `fan_out` | each box | how many modules this one depends on |
| `instability` | each box | `fan_out / (fan_in + fan_out)`: 0 = only depended upon, 1 = only depends on others |
| `edge_weight` | each arrow | how many module imports the arrow stands for |

The metrics appear in the order you ask for them. A cycle is one arrow standing for two links, so
`edge_weight` shows both values there, as `forward/backward`.

```shell
arch-blueprint draw tests/fixtures/cyclic -m 'pkg_a.*' -m 'pkg_b.*' --cycle-details \
  --metric fan_in --metric fan_out --metric instability --metric edge_weight
```

![Metrics on nodes and on a cyclic connection](docs/images/metrics.png)

`pkg_b.util` is depended on twice and depends on one module, so `instability: 0.33`. The arrow is a
cycle, so `edge_weight` reads `2/1`: two imports one way and one the other.

### Metrics in a diff

`diff` and `history` take `--metric` too. A value that is the same on both sides is drawn as on a
plain diagram. One that changed reads `old → new`, with the difference for a number:

```
class pkg_b.util <<(M, #2ECC71)>> {
  fan_in: 2
  fan_out: 1 → 2 (+1)
  instability: 0.33 → 0.5 (+0.17)
}
pkg_a <-[#C0392B,bold]-> pkg_b : edge_weight=2/1 → 2/2
```

An added module or arrow shows its new value, a removed one its old value. A cycle that appeared
reads, say, `NEW CYCLE edge_weight=2 → 2/1` (one direction before, both after); a resolved one is
drawn as the direction that remains, `cycle resolved edge_weight=2/1 → 2`. With `--changes-only`,
the modules and arrows whose shown values moved are drawn too. The legend adds
`metric: old → new (difference)`.

New metrics are self-contained plugins under `src/arch_blueprint/metrics/`. See `CLAUDE.md` for
the protocols.

## Troubleshooting

Errors are one line on stderr and exit 2 (1 when an analysis or an image could not finish; `diff`
uses its own codes, see above). Nothing fails silently: a pattern that matches no module is an
error, not an empty diagram. Common mistakes:

| Message | What to do |
| --- | --- |
| `a command comes first now — run: arch-blueprint draw …` | Earlier versions had no command word (`arch-blueprint DIR -m …`). Put `draw` first. |
| `'render' is part of 'draw' now` | `arch-blueprint draw graph.json …` draws a snapshot. |
| `src is itself a package — pass the directory that contains it` | `PROJECT_DIR` is the directory the packages sit in, not a package. |
| `. keeps its packages in src` | A src layout: run `arch-blueprint draw src`. |
| `no package 'myap' in src (found: myapp, tests)` | A typo in `-m`; the message lists the packages that exist. |
| `no modules matched: 'myapp.zzz.*'` | The package exists but the pattern selects nothing inside it. |
| `-m 'myapp' draws the package as a single box` | Use `myapp.*` or `myapp.**`. |
| `-m took 'src' as a pattern` | `-m` takes every value up to the next option: put `PROJECT_DIR` first, or give one pattern per `-m`. |
| `-f puml-png needs 'plantuml' on PATH` | Install PlantUML / D2, or write source (`-o x.puml`) and view it online. |
| `unknown metric 'fanin'. Available: …` | See `arch-blueprint --list-metrics`. |
| `--links applies when building a graph; a snapshot already records its link level` | Pass `--links` when saving the snapshot (`draw DIR --links module -o graph.json`), not when drawing it. |
| `cannot diff a namespace-level snapshot against a module-level one` | Save both snapshots with the same `--links`. |
| `--metric does not go with --deps` | Draw metrics in a run without `--deps`. |
| `cannot diff a snapshot built without --deps against one built with --deps out` | Save both snapshots with the same `--deps`. |
| `warning: cannot parse '…/x.py' (…); its definitions are left out.` | With `--links class` / `class-grouped`, a file that isn't valid Python; the rest is drawn. |

A pattern resolves against `PROJECT_DIR` first, then against the installed packages. Graphing
`arch_blueprint` itself always resolves to the running copy.

## Examples

Generated with this tool from released packages, so you can reproduce them:

```shell
pip install --target /tmp/pkgs wemake-python-styleguide fastapi taskiq
arch-blueprint draw /tmp/pkgs -m 'wemake_python_styleguide.*'
```

### wemake-python-styleguide

`wemake-python-styleguide 1.8.0`: 14 modules, 29 links, **no cycles**.
Source: [`docs/images/wemake.puml`](docs/images/wemake.puml)

This is what a layered codebase looks like when nothing points back up. `checker` sits alone at
the top, everything drains toward `types`, `constants` and `compat` at the bottom, and no arrow is
red.

![wemake-python-styleguide module graph](docs/images/wemake.png)

### FastAPI

`fastapi 0.141.1`: 27 modules, 65 links, 4 cycles.
Source: [`docs/images/fastapi.puml`](docs/images/fastapi.puml)

![FastAPI module graph](docs/images/fastapi.png)

### Taskiq

`taskiq 0.12.5`: 31 modules, 87 links, 6 cycles.
Source: [`docs/images/taskiq.puml`](docs/images/taskiq.puml)

![Taskiq module graph](docs/images/taskiq.png)

### With metrics

```shell
arch-blueprint draw /tmp/pkgs -m 'taskiq.*' \
  --metric fan_in --metric fan_out --metric instability
```

`taskiq.abc` reads `fan_out: 13, instability: 1.0`: it depends on thirteen modules and nothing
depends on it, which is what an abstract-base module should look like. `taskiq.compat` is the
opposite, at `fan_in: 8, instability: 0.0`.

Source: [`docs/images/taskiq_metrics.puml`](docs/images/taskiq_metrics.puml)

![Taskiq module graph with metrics](docs/images/taskiq_metrics.png)

## Development

This project uses [`uv`](https://docs.astral.sh/uv/).

```shell
uv sync                       # install deps
uv run pytest                 # run the test suite
uv run pre-commit run -a      # lint, format, type-check, test (what CI runs)
```

## License

MIT — see [LICENSE](LICENSE).
