arch-blueprint
Draws the import graph of a Python project as an architecture diagram, in PlantUML or D2 source or as a PNG. It also shows what a branch changed in that graph, and how the graph grew commit by commit.
Each box is a module. An arrow means a module on one side imports a module on the other. Arrows
are drawn between packages, at the level where the two modules' paths part (app2.service →
app1.models is drawn as app2 → app1). A red double arrow is a cycle: two packages that
import each other.
Quick start
pip install arch-blueprint
Point it at the directory your packages sit in: src for a src layout, otherwise usually the
repository root.
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
To view a .puml without installing anything, paste it into the
PlantUML web server. For .d2 there's the
D2 playground. 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) |
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.
Commands
| Command | What it does |
|---|---|
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.
draw
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:
arch-blueprint draw tests/fixtures/cyclic -m 'pkg_a.*' -m 'pkg_b.*' --cycle-details
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. --links module draws it module to module instead:
arch-blueprint draw examples/project_root -m 'app1.*' -m 'app2.*' -m 'plugins.**' --links module
app2.service ---> app1.models
app2.service ---> plugins.auth.backend
Every arrow then ends on a box, so the boxes are drawn flat, each under its full name, without
package frames. 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.
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:
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, so drawing or diffing one takes no --links, and two
snapshots built at different levels can't be diffed.
diff
diff draws the graph with the changes marked on it. It can compare two snapshots:
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:
arch-blueprint diff --base origin/main src -o diff.png
arch-blueprint diff --base v1.0 --head v2.0 src -m 'myapp.*'
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-onlydraws just the changes and the modules they touch. That helps on a large project.--cycle-detailsadds 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, asapi (module)). - When nothing changed, you still get a valid diagram, with "No architectural changes" in the legend.
--metric NAMEdraws a metric as a plain diagram does, and shows how the changes moved it (see 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:
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. 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.
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_DIRare the ones drawn, each whole unless-mnarrows 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 asno 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 FACTORsets 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.
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
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. |
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:
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
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.
FastAPI
fastapi 0.141.1: 27 modules, 65 links, 4 cycles.
Source: docs/images/fastapi.puml
Taskiq
taskiq 0.12.5: 31 modules, 87 links, 6 cycles.
Source: docs/images/taskiq.puml
With metrics
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
Development
This project uses uv.
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.
Metadata
Release files for arch-blueprint 0.2.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| arch_blueprint-0.2.1.tar.gz | 1.8 MB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| arch_blueprint-0.2.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 1.9 MB
Release files / arch_blueprint-0.2.1.tar.gz
| Download URL | arch_blueprint-0.2.1.tar.gz |
|---|---|
| Size | 1.8 MB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
d7e98a33ec2da7c29a6b8908f20cb034a5309c06de456d75c24279ced5f6dd47
|
|
BLAKE2b-256 checksum How to use checksums |
dc1ce374e4e606a988060af2b5d2e2ac22fa498c39e21bd8750774a1dc9c1cb9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 27, 2026.
Transparency logRelease files / arch_blueprint-0.2.1-py3-none-any.whl
| Download URL | arch_blueprint-0.2.1-py3-none-any.whl |
|---|---|
| Size | 88.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
d2a9314491f013d06593514dce71f67687515e1fb9fba3bee98fdb266021e6ef
|
|
BLAKE2b-256 checksum How to use checksums |
bdffeb7deb4edca933f71615caa2e3cd7a166e73410f8b879a61372e7a498b21
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 27, 2026.
Transparency log