モジュール依存関係をNetworkXとPlotlyで可視化する

## はじめに

Pythonのプロジェクトが大きくなると、モジュール間の依存関係を把握することが難しくなります。この記事では、Pythonの[AST（Abstract Syntax Tree）モジュール](https://docs.python.org/3/library/ast.html)を用いてコードベースを解析し、[NetworkX](https://networkx.org/)と[Plotly](https://plotly.com/python/)を使ってモジュール間のインポート関係を可視化する方法を紹介します。

## 目次

- [ディレクトリ構成](#ディレクトリ構成)
- [ソースコードの説明](#ソースコードの説明)
  - [依存関係の解析と可視化コード](#依存関係の解析と可視化コード)
  - [各モジュールの内容](#各モジュールの内容)
- [実行結果](#実行結果)
- [まとめ](#まとめ)

## ディレクトリ構成

以下のようなディレクトリ構成のプロジェクトを例として使用します。
※機械学習プロジェクトを意識した例としましたが、任意のディレクトリ構成に対応しています。
`visualize_imports_relationship.py`が可視化ソースコードです。それ以外は、サンプルコードとなっています。

```
src/
├── visualize_imports_relationship.py
├── main.py
├── data_processing/
│   ├── data_loader.py
│   ├── data_cleaner.py
│   └── __init__.py
├── models/
│   ├── base_model.py
│   ├── linear_model.py
│   ├── neural_network.py
│   └── __init__.py
├── utils/
│   ├── logger.py
│   ├── config.py
│   └── __init__.py
```

## ソースコードの説明

### 依存関係の解析と可視化を行うソースコード

以下のコードは、プロジェクト内のPythonファイルを解析し、モジュール間のインポート関係をNetworkXでグラフ化し、Plotlyで可視化するものです。

```python
import ast
import fnmatch
import os

import networkx as nx
import plotly.graph_objects as go

EXCLUDE_PATTERN = ["test_*.py", "visualize_imports_relationship.py", "*__init__.py"]


def find_imports(file_path):
    """
    指定されたファイルのインポートを解析します。

    Args:
        file_path (str): ファイルパス。

    Returns:
        list[str]: インポートされたモジュールのリスト。
    """
    with open(file_path, "r", encoding="utf-8") as f:
        tree = ast.parse(f.read(), filename=file_path)

    imports = []
    for node in ast.walk(tree):
        if isinstance(node, ast.Import):
            for alias in node.names:
                imports.append(alias.name)
        elif isinstance(node, ast.ImportFrom):
            module = node.module if node.module else ""
            for n in node.names:
                if module:
                    imports.append(f"{module}.{n.name}")
                else:
                    imports.append(n.name)
    return imports


def analyze_codebase(src_root):
    """
    ディレクトリ内のPythonファイルを解析し、インポートを収集します。

    Args:
        src_root (str): ソースコードのルートディレクトリ。

    Returns:
        tuple: ファイルごとのインポートリストとモジュール名からファイルパスへのマッピング。
    """
    file_imports = {}
    module_name_to_path = {}
    for root, _, files in os.walk(src_root):
        for file in files:
            if not file.endswith(".py"):
                continue
            file_path = os.path.join(root, file)
            rel_path = os.path.relpath(file_path, src_root)
            if any(fnmatch.fnmatch(rel_path, pattern) for pattern in EXCLUDE_PATTERN):
                continue
            module_name = os.path.splitext(rel_path)[0].replace(os.sep, ".")
            module_name = module_name.lstrip(".")  # 不要な先頭のドットを削除
            full_module_name = f"{src_root}.{module_name}"
            module_name_to_path[full_module_name] = rel_path
            imports = find_imports(file_path)
            file_imports[rel_path] = imports
    return file_imports, module_name_to_path


def find_custom_modules(file_imports, module_name_to_path):
    """
    自作モジュールのみのインポートを抽出します。

    Args:
        file_imports (dict): ファイルごとのインポートリスト。
        module_name_to_path (dict): モジュール名からファイルパスへのマッピング。

    Returns:
        dict: 自作モジュールのみのインポートリスト。
    """
    custom_imports = {}
    module_names = set(module_name_to_path.keys())

    for filepath, imports in file_imports.items():
        custom_only = [imp for imp in imports if imp in module_names]
        custom_imports[filepath] = custom_only

    return custom_imports


def build_dependency_graph(custom_imports, module_name_to_path):
    """
    ファイル間の依存関係グラフを構築します。

    Args:
        custom_imports (dict): 自作モジュールのみのインポートリスト。
        module_name_to_path (dict): モジュール名からファイルパスへのマッピング。

    Returns:
        nx.DiGraph: 依存関係グラフ。
    """
    G = nx.DiGraph()
    for filepath, imports in custom_imports.items():
        for imp in imports:
            if imp in module_name_to_path:
                imp_relpath = module_name_to_path[imp]
                G.add_edge(filepath, imp_relpath)
    return G


def visualize_graph(graph):
    """
    依存関係グラフを可視化します。

    Args:
        graph (nx.DiGraph): 依存関係グラフ。
    """
    pos = nx.spring_layout(graph)
    edge_trace = []
    node_trace = go.Scatter(
        x=[],
        y=[],
        text=[],
        mode="markers+text",
        textposition="top center",
        marker=dict(
            showscale=True, colorscale="YlGnBu", color=[], size=[], sizemode="area"
        ),
    )

    for node in graph.nodes():
        x, y = pos[node]
        node_trace["x"] += (x,)
        node_trace["y"] += (y,)
        node_trace["text"] += (node,)
        node_degree = graph.degree(node)
        node_trace["marker"]["size"] += (1000 + node_degree * 200,)
        node_trace["marker"]["color"] += (node_degree,)

    for edge in graph.edges():
        x0, y0 = pos[edge[0]]
        x1, y1 = pos[edge[1]]
        edge_trace.append(
            go.Scatter(
                x=(x0, x1, None),
                y=(y0, y1, None),
                line=dict(width=1, color="black"),
                mode="lines",
            )
        )

    fig = go.Figure(
        data=edge_trace + [node_trace],
        layout=go.Layout(
            showlegend=False,
            hovermode="closest",
            margin=dict(b=20, l=5, r=5, t=40),
            xaxis=dict(showgrid=False, zeroline=False),
            yaxis=dict(showgrid=False, zeroline=False),
        ),
    )

    fig.show()


if __name__ == "__main__":
    src_root = "src"
    file_imports, module_name_to_path = analyze_codebase(src_root)
    print("File Imports:", file_imports)
    print("Module Name to Path:", module_name_to_path)
    custom_imports = find_custom_modules(file_imports, module_name_to_path)
    print("Custom Imports:", custom_imports)
    dependency_graph = build_dependency_graph(custom_imports, module_name_to_path)
    print("Edges in Dependency Graph:", list(dependency_graph.edges()))
    visualize_graph(dependency_graph)
```

### 各モジュールの内容

以下はサンプルコードです。
※内容は何でもOKです。

#### `src/main.py`

```python
from src.data_processing import data_loader
from src.models import linear_model
from src.utils import config, logger


def main():
    logger_instance = logger.Logger()
    data = data_loader.load_data()
    model = linear_model.LinearModel()
    model.train(data)
    logger_instance.log(config.CONFIG)


if __name__ == "__main__":
    main()
```

#### `src/data_processing/data_loader.py`

```python
from src.data_processing import data_cleaner


def load_data():
    raw_data = "raw data"
    data = data_cleaner.clean_data(raw_data)
    return data
```

#### `src/data_processing/data_cleaner.py`

```python
def clean_data(data):
    return f"cleaned {data}"
```

#### `src/models/base_model.py`

```python
class BaseModel:
    def train(self, data):
        raise NotImplementedError("Train method must be implemented by subclasses.")
```

#### `src/models/linear_model.py`

```python
from src.models import base_model


class LinearModel(base_model.BaseModel):
    def train(self, data):
        print(f"Training Linear Model with {data}")
```

#### `src/models/neural_network.py`

```python
from src.models import base_model


class NeuralNetwork(base_model.BaseModel):
    def train(self, data):
        print(f"Training Neural Network with {data}")
```

#### `src/utils/logger.py`

```python
class Logger:
    def log(self, message):
        print(f"[LOG]: {message}")
```

#### `src/utils/config.py`

```python
CONFIG = {"learning_rate": 0.01, "epochs": 10}
```

## 実行結果

上記のコードを実行すると、以下のようなモジュール間の依存関係グラフが表示されます。


![newplot.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2627015/37a9286f-c307-ad0b-ae8b-2cf268dbc11d.png)


- ノードは各Pythonファイルを表し、エッジはインポート関係を示しています。
- ノードのサイズと色は、そのノードの次数（他のノードとの接続数）に基づいています。

**コンソール出力例:**

```bash
python src/visualize_imports_relationship.py 
File Imports: {'main.py': ['src.data_processing.data_loader', 'src.models.linear_model', 'src.utils.config', 'src.utils.logging'], 'data\\data.py': [], 'data_processing\\data_cleaner.py': [], 'data_processing\\data_loader.py': ['src.data_processing.data_cleaner'], 'models\\base_model.py': [], 'models\\linear_model.py': ['src.models.base_model'], 'models\\neural_network.py': ['src.models.base_model'], 'utils\\config.py': [], 'utils\\logging.py': []}
Module Name to Path: {'src.main': 'main.py', 'src.data.data': 'data\\data.py', 'src.data_processing.data_cleaner': 'data_processing\\data_cleaner.py', 'src.data_processing.data_loader': 'data_processing\\data_loader.py', 'src.models.base_model': 'models\\base_model.py', 'src.models.linear_model': 'models\\linear_model.py', 'src.models.neural_network': 'models\\neural_network.py', 'src.utils.config': 'utils\\config.py', 'src.utils.logging': 'utils\\logging.py'}
Custom Imports: {'main.py': ['src.data_processing.data_loader', 'src.models.linear_model', 'src.utils.config', 'src.utils.logging'], 'data\\data.py': [], 'data_processing\\data_cleaner.py': [], 'data_processing\\data_loader.py': ['src.data_processing.data_cleaner'], 'models\\base_model.py': [], 'models\\linear_model.py': ['src.models.base_model'], 'models\\neural_network.py': ['src.models.base_model'], 'utils\\config.py': [], 'utils\\logging.py': []}
Edges in Dependency Graph: [('main.py', 'data_processing\\data_loader.py'), ('main.py', 'models\\linear_model.py'), ('main.py', 'utils\\config.py'), ('main.py', 'utils\\logging.py'), ('data_processing\\data_loader.py', 'data_processing\\data_cleaner.py'), ('models\\linear_model.py', 'models\\base_model.py'), ('models\\neural_network.py', 'models\\base_model.py')]
```

## まとめ

この方法を使うことで、プロジェクト内のモジュール間の依存関係を視覚的に把握することができます。特に大規模なプロジェクトでは、依存関係の循環や複雑な関係性を発見するのに役立ちます。

**ポイント:**

- PythonのASTモジュールを使用してコードを解析。
- NetworkXで依存関係グラフを構築。
- Plotlyでグラフを可視化。
- 自作モジュールのみを対象にすることで、外部ライブラリの影響を除外。