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.

FileResponsibilityPhase
__init__.pypublic API—
__main__.pyCLI entry point—
core.pyorchestration—
discovery.pyfile discovery1
parser.pyreference parsing2
graph.pygraph construction3
analysis.pyanalysis passes4
git.pygit integration5
renderer.pyrendering6
pool.pyGIL-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 statementlocal_namecanonical
import aaa
import a as bba
import a.b as ababa.b
from a import bba::b if defined, else a.b
from a import b as ccsame lookup as above
from .x import yyresolved 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.Call with ast.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) → edge mod::B → mod::A
  • Decorators — bare @foo and 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.

GraphNodesEdges
Modulepkg.modA imports B
Callablepkg.mod::Func, pkg.mod::Class.methodA 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.