Understand your Python dependencies. Find cycles, dead modules, and architecture problems.
Project description
DependaMan
Understand your Python project's internal structure. Find cycles, dead modules, hotspots, and architecture problems — visualized as an interactive graph.
No external Python dependencies. Pure stdlib only.
Goal
Given a Python project directory, DependaMan produces an interactive HTML graph showing:
- Which modules import which (directed dependency graph)
- Which modules are never imported (dead code candidates)
- Circular import chains
- Modules with high fan-in (many dependents) or high fan-out (many dependencies)
- Module size (lines of code, number of functions/classes)
- Git churn: how often each module changes
Architecture
Phase 1 — File Discovery
Walk the project directory, collect all .py files, and determine the package
root. Distinguish internal modules from external ones (stdlib + third-party are
ignored).
Phase 2 — Import Parsing
Use ast to parse each file and extract import and from ... import
statements. Resolve relative imports. Filter to internal-only imports.
Phase 3 — Graph Construction
Build a directed graph as an adjacency structure:
- Node = internal module
- Edge A → B = "module A imports module B"
Attach metadata to each node: file path, line count, function/class count.
Phase 4 — Analysis
Run these passes on the graph:
- Dead code: nodes with no incoming edges and not an entry point
- Circular imports: detect cycles (DFS-based)
- Hotspots: nodes ranked by fan-in (most imported)
- Coupling: nodes ranked by fan-out (imports the most)
Phase 5 — Git Integration
Use subprocess + git log to compute per-file:
- Commit frequency (how often it changes)
- Lines added/removed over time (churn)
- Last author that changed the file
Attach this data to graph nodes. Optional: skipped if the project is not a git repo.
Phase 6 — HTML Output
Generate a self-contained .html file (or return HTML as a string for web
integration).
The HTML template is a static string embedded in Python. Only the data changes between runs. Python serializes the graph to JSON and injects it into the template — no templating library needed:
import json
data = json.dumps({"nodes": [...], "edges": [...]})
html = TEMPLATE.replace("__GRAPH_DATA__", data)
The template contains a <script> block that reads the injected data and
renders the graph using the browser's <canvas> or SVG API. No external JS
libraries required.
The graph supports:
- Hover tooltips: quick summary (import count, churn score)
- Click modals: full detail panel (git log, list of dependents/dependencies, size metrics)
The output function signature is designed to be framework-agnostic:
def render(graph, analysis) -> str: # returns HTML string
...
This makes it trivial to plug into FastAPI, Flask, or any other framework:
# FastAPI example
@app.get("/graph", response_class=HTMLResponse)
def dependency_graph():
graph = build_graph(".")
analysis = analyze(graph)
return render(graph, analysis)
Package Structure
DependaMan is distributed as a Python package (dependaman). The current
layout:
dependaman/
__init__.py # public API: dependaman()
__main__.py # CLI entry point
core.py # orchestration
discovery.py # Phase 1 — file discovery
parser.py # Phase 2 — import parsing
graph.py # Phase 3 — graph construction
analysis.py # Phase 4 — analysis passes
git.py # Phase 5 — git integration
renderer.py # Phase 6 — HTML output
pool.py # GIL-aware executor selection
Entry point via pyproject.toml:
[project.scripts]
dependaman = "dependaman.__main__:dependaman"
Usage
CLI:
dependaman # analyzes current directory, opens browser
dependaman /path/to/project
Python API:
from dependaman import dependaman
html = dependaman(".", in_memory=True) # returns HTML string
dependaman(".") # writes output.html + opens browser
Roadmap
- Phase 1: File discovery
- Phase 2: Import parsing (
ast) - Phase 3: Graph construction
- Phase 4: Analysis (dead code, cycles, hotspots)
- Phase 5: Git integration
- Phase 6: HTML renderer
- Phase 7: Unused symbol detection (functions, classes, methods never imported)
- Phase 8: Installable package (
uv pip install -e .) - Phase 9: CLI entry point with auto project root detection and browser open
- Phase 10: Performance — parallel git stats (ThreadPoolExecutor), GIL-aware pool for parsing and graph construction
- Phase 11: Published to PyPI (v1.0.2)
Phase 12 — Visualization overhaul
- Hierarchical circle packing layout — packages contain sub-packages, no circle/node overlap
- Zoom-to-fit button +
Fshortcut (fits all package circles in view) - Color encoding — node fill: (pastel) blue→red by commit frequency; package border brightness by nesting depth
- Test filter toggle — hide/show nodes, edges, and packages containing "test"
- Node spacing fix + minimum arrow length
- Click interaction — select node + open modal; click package circle to zoom into it; click empty to deselect
- Modal navigation — click any import/imported-by entry to jump to that node
- Middle-click panning; Escape closes modal and deselects in one press
- Search/filter bar — fuzzy search by module name, jump to and highlight it
- Performance — rAF-throttled panning/zoom, precomputed draw order
- Publish v1.0.3
Phase 13 — Call graph: just tell me why (v1.1, in progress)
Semantic analysis at the function/method level. The module graph tells you what depends on what — the call graph tells you why.
Architecture
Two graphs, structurally identical, joined implicitly by canonical prefix:
| Graph | Nodes | Edges | Question it answers |
|---|---|---|---|
| Module (existing) | pkg.mod |
A imports B | What depends on what? Cycles. |
| Callable (new) | pkg.mod::Func or pkg.mod::Class.method |
A calls B | Who calls what? If I change X, what breaks? |
Every callable canonical contains its module canonical as a prefix — split on
:: to get the parent module. The two graphs are kept independent so all
existing analyzers (fanin_analyzer, fanout_analyzer, circular_imports)
apply to the callable graph unchanged.
Definitions (definition_collector + a project-wide upfront pass mirroring
module_level_node_generator): walk the top-level AST nodes of each module and
collect all FunctionDef, AsyncFunctionDef, and ClassDef names in canonical
form:
module.path::function_name
module.path::ClassName
module.path::ClassName.method_name
The project-wide set project_definitions is built once upfront so call
resolution is a deterministic lookup against known callables, not a heuristic on
the :: separator.
Alias mapper: a single local_name → canonical dict capturing all
imports, aliased or not. Non-aliased forms like from . import discovery or
import dependaman.parser bind a local name (discovery, dependaman) that
the chain walker needs to resolve.
| Import statement | local_name | canonical |
|---|---|---|
import a |
a |
a |
import a as b |
b |
a |
import a.b |
a |
a |
import a.b as ab |
ab |
a.b |
from a import b |
b |
a::b if in project_definitions, else a.b if in project_nodes |
from a import b as c |
c |
same lookup as above |
from .x import y |
y |
resolved relatively, then same lookup |
Lookup priority: callable (::) before module (.) — matches Python's
__init__.py-name-wins-over-submodule rule.
Call collection (parse_calls, caller-scoped): for each top-level
FunctionDef / AsyncFunctionDef / ClassDef (and methods inside classes),
walk the body and emit edges source_canonical → target_canonical. Module-scope
calls (outside any function) attribute to a synthetic source mod::<module> so
entry-point invocations like cli() in __main__.py aren't lost. Nested
function defs are not separate nodes; calls inside them attribute to the
outermost enclosing callable.
References tracked as edges:
ast.Calldirect:helper()→ look uphelperin alias_mapper.ast.Callattribute: walk the chain (np.linalg.norm→["np", "linalg", "norm"]), resolve the root via alias_mapper, then reconstruct candidates againstproject_definitionsandproject_nodes— first match wins. If the root is not in alias_mapper (local variable), fall back to scoped naive name-matching (see below).ClassDef.bases— inheritance:class B(A)→ edgemod::B → mod::A.decorator_list— decorators (bare@fooisast.Name, parameterized@foo(arg)isast.Call; same target either way — unwrap and resolve).
Returns call_edges: dict[str, set[str]] (source → targets). Externals are
filtered at collection time (target not in project_definitions ∪ project_nodes
→ drop).
Scoped naive name-matching (fallback for unresolved attribute calls): when
obj.method() can't be resolved because obj is a local variable, match the
method name against canonicals in scope for this module — its imports
(alias_mapper values) and its own class definitions. Imprecise but bounded:
in_scope = set(alias_mapper.values()) | local_class_canonicals
matches = {
f"{c}.{method_name}"
for c in in_scope
if f"{c}.{method_name}" in project_definitions
}
This avoids the false-dead case where a method is only ever called on local
instances. Trade: a method named process defined in two in-scope classes
produces edges to both for a single call site. Acceptable for v1.1.
Callable graph construction: aggregate per-module call_edges into a
project-wide dict[str, set[str]], mirroring speed_grapher. Fan-in / fan-out
/ cycle analyzers apply unchanged.
Dead callable detection: a callable is dead when nothing references it — zero fan-in in the callable graph and its canonical not present in the project import set:
dead = {c for c in project_definitions
if callable_fanin[c] == 0 and c not in project_imports}
Known v1.1 limitation: obj.method() where obj is a local variable
without type information is resolved via scoped naive name-matching only. True
precision requires type inference; deferred to v1.2 (see below).
v1.1 checklist
- Extract all function, method, and class definitions per module (AST)
- Alias mapper — basic aliased-import handling
-
ast.Namedirect call resolution - Extend alias mapper to non-aliased imports (
import x,from . import x) - Project-wide definition collection (upfront pass —
callable_level_node_generator) -
ast.Attributechain walker with lookup-driven canonical reconstruction - Caller-scoped call collection — emit edges (source → set of targets)
- Synthetic
mod::<module>source for module-scope calls - Inheritance edges from
ClassDef.bases - Decorator edges from
decorator_list(bare +Call-wrapped) - Scoped naive name-matching fallback for unresolved
ast.Attributecalls - Callable graph construction (mirror of module-graph builder)
- Reuse fan-in / fan-out / cycles analyzers on the callable graph
- Call-aware dead-callable detection
- Expose callable graph in JSON output alongside the module graph
- Visualize callable edges in the module modal (expandable function-level canvas view deferred to v1.2)
- Function references passed as
Callarguments / keyword arguments tracked as edges — catchespool.submit(fn),sorted(xs, key=fn)patterns; closes the false-positive wherepool.submit(_get_git_stats, ...)marked_get_git_statsas dead - Single-pass file parsing —
speed_grapherreads and AST-parses each module once, fanning the result into project imports, module graph, and callable graph; replaces three separate pool rounds (~30% wall-clock speedup on a 1 354-file project: 3.48s → 2.44s) - CHANGELOG + bump to v1.1.0 (publish step remains for the user)
Deferred to v1.2
- Instance method resolution via type annotations (
ast.AnnAssign+ function-arg + return annotations →var_typesmap → preciseobj.method()resolution, replacing the naive name-match fallback) - THIS IS BEING EVALUATED Expandable function-level view drawn on the canvas — call edges as visual arrows between callables inside their parent module circle. v1.1 ships the callable graph as a modal-only view; the in-canvas rendering is the next visualization step.
Future ideas
- Documentation site
--format jsonoutput option for external tooling
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file dependaman-1.1.0.tar.gz.
File metadata
- Download URL: dependaman-1.1.0.tar.gz
- Upload date:
- Size: 34.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.16 {"installer":{"name":"uv","version":"0.11.16","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2723f4087704391c55206a763c54f1c23d3521200e6b6f5aa152cfcb83a91aef
|
|
| MD5 |
b272ad611597979974d002853aa62d3e
|
|
| BLAKE2b-256 |
c00186e7a1d92a51c0a9a774bc41d6544bc9899e150dbb01b79e1a5c9be061de
|
File details
Details for the file dependaman-1.1.0-py3-none-any.whl.
File metadata
- Download URL: dependaman-1.1.0-py3-none-any.whl
- Upload date:
- Size: 32.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.16 {"installer":{"name":"uv","version":"0.11.16","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7f22ff413b5210daabf011bafa504518d7271c54bb00654aee20c1ec0d8e40c6
|
|
| MD5 |
dda14cfbb696d02b944f9989c69b79d1
|
|
| BLAKE2b-256 |
48a8866fb093f85156a306f82d348ba908fbdb72cd0164a655a46c5cba3b4dc4
|