Design
Architecture
DependaMan is a six-phase pipeline, and the phases are independent — the analysis passes in Phase 4 know nothing about git, and the renderer in Phase 6 knows nothing about the file system.
Everything the pipeline passes around is a string, and there are only two kinds.
A module node is dotted, pkg.mod. A callable canonical holds exactly
one ::, pkg.mod::func or pkg.mod::Class.method. Splitting a canonical on
:: gives the module that owns it.
Package Structure
One file per phase. Click a phase to jump to it.
| File | Responsibility | Phase |
|---|---|---|
__init__.py | public API | — |
__main__.py | CLI entry point | — |
core.py | orchestration | — |
discovery.py | file discovery | 1 |
parser.py | reference parsing | 2 |
graph.py | graph construction | 3 |
analysis.py | analysis passes | 4 |
git.py | git integration | 5 |
renderer.py | rendering | 6 |
pool.py | GIL-aware executors | * |
Phase 1 — File Discovery ↑
Walk the project directory, collect every .py file, and group them into one
Package per source directory. Excluded directories (.venv, __pycache__,
build, …) are skipped.
in Path("test/sample_package")
out Package(name="sample_pkg", modules=[Module, Module, ...])
Module(name="sample_a",
path=Path("sample_pkg/sample_a.py"), root-relative
full_path=Path("/abs/.../sample_a.py")) absolute
Two paths per module. path is relative to the analyzed root and is what every
node name is built from; full_path is absolute and is what gets read from
disk. Because node names come from path, the root you hand in decides what
everything is called — which is why analyzing the directory inside your
package resolves almost no imports.
Module(path="sample_pkg/sample_sub/sample_deep.py")
-> "sample_pkg.sample_sub.sample_deep"
Phase 2 — Parsing ↑
Parse each file with ast and extract two things: the imports, and every
reference a callable makes. No regex, no string matching — the AST gives exact
source structure. A file that cannot be read or parsed is reported as a warning
and skipped.
Imports become strings: one for the module reached, plus one per imported name. Three different spellings converge on the same pair — the graph should not care how the import was written.
from ..sample_b import there \
from .sample_b import there > "sample_pkg.sample_b"
from sample_pkg.sample_b import there / "sample_pkg.sample_b::there"
from os.path import join, split -> "os.path"
"os.path::join"
"os.path::split"
External imports are emitted in the same shape and dropped later by the project filter, never here.
Alias mapper
A single local_name → canonical dict capturing every import, aliased or not.
It is what lets a bare there() in a function body become a fully qualified
edge.
| Import statement | local_name | canonical |
|---|---|---|
import a | a | a |
import a as b | b | a |
import a.b as ab | ab | a.b |
from a import b | b | a::b if defined, else a.b |
from a import b as c | c | same lookup as above |
from .x import y | y | resolved relatively, then same lookup |
Definitions made in the file are added to the same dict, so a call to a sibling function resolves exactly like an import — which is also Python’s own binding order.
Reference collection
For each top-level FunctionDef, AsyncFunctionDef and ClassDef, walk the
body and emit edges source_canonical → target_canonical. Nested definitions
are not separate nodes; calls inside them attribute to the outermost enclosing
callable. Module-scope calls attribute to a synthetic mod::<module> source, so
entry-point invocations are not lost.
sample_pkg.sample_refs::run -> sample_pkg.sample_refs::positional_ref
sample_pkg.sample_refs::keyword_ref
sample_pkg.sample_entrypoint::<module> -> sample_pkg.sample_entrypoint::main
References tracked:
- Direct calls —
ast.Callwithast.Name: look up in the alias mapper - Attribute chains —
pkg.models.User.save(): walk the chain, resolve the root, then slide the::one part to the right until a candidate matches a known definition - Inheritance —
ClassDef.bases:class B(A)→ edgemod::B → mod::A - Decorators — bare
@fooand parameterized@foo(arg), resolved alike - Callable-as-argument —
pool.submit(fn),sorted(xs, key=fn): a function passed as data emits an edge, which keeps it off the dead list
Property roles are kept apart, because three functions share one name:
@property def bar -> pkg.mod::Config.bar@get
@bar.setter def bar -> pkg.mod::Config.bar@set
@bar.deleter def bar -> pkg.mod::Config.bar@del
@typing.overload stubs deliberately keep collapsing into one canonical, since
they are semantically one function.
Phase 3 — Graph Construction ↑
Build two directed graphs as adjacency dictionaries, filtered to project-internal targets.
| Graph | Nodes | Edges |
|---|---|---|
| Module | pkg.mod | A imports B |
| Callable | pkg.mod::Func, pkg.mod::Class.method | A calls B |
in project_nodes = {"pkg", "pkg.mod"} dotted
project_definitions = {"pkg.mod::func"} with "::"
out module_graph = {"pkg.a": {"pkg.b"}}
callable_graph = {"pkg.a::hello": {"pkg.b::there"},
"pkg.entry::<module>": {"pkg.entry::main"}}
project_imports = {"pkg.b", "pkg.b::there", "typing"}
project_imports is the only unfiltered set in the pipeline — external names
such as typing live in it, and nowhere else. callable_graph holds keys that
are in no definition set, the ::<module> sources, so the two never match
exactly.
The graphs stay independent and share only the naming convention, which is why every analysis pass in Phase 4 applies to both without modification.
Phase 4 — Analysis ↑
Independent passes, each run over both graph layers.
Fan-in — how many nodes point at this one. Hotspots rank highest.
in nodes = {"pkg", "pkg.mod", "pkg.other"}
graph = {"pkg.mod": {"pkg.other"}, "pkg.other": set()}
out {"pkg": 0, "pkg.mod": 0, "pkg.other": 1}
Fan-out — how many nodes this one points at. Coupling ranks highest.
out {"pkg.mod": 1, "pkg.other": 0}
Cycles — DFS, reporting the full path. The first and last entry are the same node.
in graph = {"pkg.a": {"pkg.b"}, "pkg.b": {"pkg.a"}}
out [["pkg.a", "pkg.b", "pkg.a"]]
Dead callables — zero call-graph fan-in and absent from the project import set. The import check protects names exported as public API, whose callers are downstream projects rather than visible call sites.
in project_definitions = {"pkg.mod::used", "pkg.mod::orphan"}
callable_fanin = {"pkg.mod::used": 1, "pkg.mod::orphan": 0}
project_imports = {"pkg.other::imported"}
out {"pkg.mod::orphan"}
Dead code is detected on the callable layer only. A module is never reported as dead.
Phase 5 — Git Integration ↑
One git log --numstat call for the whole repository, parsed into commit count,
lines added and removed, and last author per file.
git log --numstat --no-renames --pretty=format:%x1e%ae -- '*.py'
3 0 sample_pkg/sample_a.py added, removed, path (tab separated)
- - assets/logo.png binary files report no line counts
The log is newest-first, so the first sighting of a path carries its most recent author. Paths are relative to the repository root however deep the analysis started, so they are rekeyed onto module nodes before being attached to the graph.
out _get_git_stats {"test/sample_package/sample_pkg/sample_a.py": {...}}
git_commit_freq {"sample_pkg.sample_a": {"n_commits": 1,
"added_lines": 3,
"removed_lines": 0,
"last_author": "you@ex.com"}}
Entirely optional. A missing git executable, a directory outside a repository,
and a repository without commits all degrade to zeroed statistics instead of
failing the analysis.
Phase 6 — Rendering ↑
The analysis is a dictionary, and rendering it is the last step. There are two renderers over the same payload, so nothing about the analysis is HTML-only.
out {"cycles": [["pkg.a", "pkg.b", "pkg.a"]],
"callable_cycles": [[...]],
"packages": [{"id": "pkg", "modules": [...]}]}
module id edges fan_in fan_out n_commits added_lines removed_lines
last_author with_symbols_to_review callables
callable id edges fan_in fan_out is_dead
JSON is that payload verbatim, which makes the whole analysis available to anything that would rather have data than a picture:
dependaman --format json -o - | jq '.cycles'
HTML is the star: a single self-contained file. The template and the graph
script ship as two files inside the package, dependaman_template.html and
dependaman_graph.js. Only the data changes between runs — Python serializes
the payload to JSON and substitutes both into the template:
html_data = html_data.replace("__GRAPH_DATA__", json_data)
html_data = html_data.replace("__GRAPH_JS__", f"<script>{js_data}</script>")
The injected script renders the graph with the browser’s canvas API. No external JS libraries. Works fully offline.
Deterministic output
The payload is assembled out of sets, whose iteration order is not stable between interpreters, so the same project could serialize differently on two runs. Order carries no meaning here, so it is normalized once — on the finished dictionary, immediately before serialization.
Cycles need one extra step. A cycle is reported from wherever the traversal entered it, and sorting cannot undo a rotation, so each is rotated to start at its smallest node:
["pkg.b", "pkg.a", "pkg.b"] -> ["pkg.a", "pkg.b", "pkg.a"]
["pkg.a", "pkg.b", "pkg.a"] -> ["pkg.a", "pkg.b", "pkg.a"]
Concurrency ↑
Parsing (CPU-bound) — pure computation. When the module count is high
enough, ProcessPoolExecutor bypasses the GIL and uses multiple cores. On a
GIL-less Python build (3.13+ free-threaded), ThreadPoolExecutor is used
instead — threads already run truly in parallel. A minimum module threshold
guards the pool: spawning processes has real startup costs, and for small
projects the overhead exceeds the benefit.