Metadata-Version: 2.4
Name: dependaman
Version: 1.1.0
Summary: Understand your Python dependencies. Find cycles, dead modules, and architecture problems.
Author-email: Jacobo Bedoya <jacobobedoya@gmail.com>
License-Expression: MIT
Project-URL: Homepage, https://codeberg.org/jacobitosuperstar/DependaMan
Classifier: Programming Language :: Python :: 3
Classifier: Operating System :: OS Independent
Requires-Python: >=3.12
Description-Content-Type: text/markdown
License-File: LICENSE
Dynamic: license-file

# 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:

```python
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:

```python
def render(graph, analysis) -> str:  # returns HTML string
    ...
```

This makes it trivial to plug into FastAPI, Flask, or any other framework:

```python
# 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:**

```bash
dependaman              # analyzes current directory, opens browser
dependaman /path/to/project
```

**Python API:**

```python
from dependaman import dependaman

html = dependaman(".", in_memory=True)   # returns HTML string
dependaman(".")                          # writes output.html + opens browser
```

---

## Roadmap

- [x] Phase 1: File discovery
- [x] Phase 2: Import parsing (`ast`)
- [x] Phase 3: Graph construction
- [x] Phase 4: Analysis (dead code, cycles, hotspots)
- [x] Phase 5: Git integration
- [x] Phase 6: HTML renderer
- [x] Phase 7: Unused symbol detection (functions, classes, methods never
      imported)
- [x] Phase 8: Installable package (`uv pip install -e .`)
- [x] Phase 9: CLI entry point with auto project root detection and browser open
- [x] Phase 10: Performance — parallel git stats (ThreadPoolExecutor), GIL-aware
      pool for parsing and graph construction
- [x] Phase 11: Published to PyPI (v1.0.2)

### Phase 12 — Visualization overhaul

- [x] Hierarchical circle packing layout — packages contain sub-packages, no
      circle/node overlap
- [x] Zoom-to-fit button + `F` shortcut (fits all package circles in view)
- [x] Color encoding — node fill: (pastel) blue→red by commit frequency; package
      border brightness by nesting depth
- [x] Test filter toggle — hide/show nodes, edges, and packages containing
      "test"
- [x] Node spacing fix + minimum arrow length
- [x] Click interaction — select node + open modal; click package circle to zoom
      into it; click empty to deselect
- [x] Modal navigation — click any import/imported-by entry to jump to that node
- [x] Middle-click panning; Escape closes modal and deselects in one press
- [x] Search/filter bar — fuzzy search by module name, jump to and highlight it
- [x] Performance — rAF-throttled panning/zoom, precomputed draw order
- [x] 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.Call` direct: `helper()` → look up `helper` in alias_mapper.
- `ast.Call` attribute: walk the chain (`np.linalg.norm` →
  `["np", "linalg", "norm"]`), resolve the root via alias_mapper, then
  reconstruct candidates against `project_definitions` and `project_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)` → edge `mod::B → mod::A`.
- `decorator_list` — decorators (bare `@foo` is `ast.Name`, parameterized
  `@foo(arg)` is `ast.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:

```python
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:

```python
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

- [x] Extract all function, method, and class definitions per module (AST)
- [x] Alias mapper — basic aliased-import handling
- [x] `ast.Name` direct call resolution
- [x] Extend alias mapper to non-aliased imports (`import x`, `from . import x`)
- [x] Project-wide definition collection (upfront pass —
      `callable_level_node_generator`)
- [x] `ast.Attribute` chain walker with lookup-driven canonical reconstruction
- [x] Caller-scoped call collection — emit edges (source → set of targets)
- [x] Synthetic `mod::<module>` source for module-scope calls
- [x] Inheritance edges from `ClassDef.bases`
- [x] Decorator edges from `decorator_list` (bare + `Call`-wrapped)
- [x] Scoped naive name-matching fallback for unresolved `ast.Attribute` calls
- [x] Callable graph construction (mirror of module-graph builder)
- [x] Reuse fan-in / fan-out / cycles analyzers on the callable graph
- [x] Call-aware dead-callable detection
- [x] Expose callable graph in JSON output alongside the module graph
- [x] Visualize callable edges in the module modal (expandable function-level
      canvas view deferred to v1.2)
- [x] Function references passed as `Call` arguments / keyword arguments tracked
      as edges — catches `pool.submit(fn)`, `sorted(xs, key=fn)` patterns;
      closes the false-positive where `pool.submit(_get_git_stats, ...)` marked
      `_get_git_stats` as dead
- [x] Single-pass file parsing — `speed_grapher` reads 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)
- [x] 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_types` map → precise
      `obj.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 json` output option for external tooling
