codezoom 0.1.0.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codezoom/__init__.py +1 -0
- codezoom/__main__.py +5 -0
- codezoom/cli.py +47 -0
- codezoom/detect.py +58 -0
- codezoom/extractors/__init__.py +0 -0
- codezoom/extractors/base.py +20 -0
- codezoom/extractors/java/__init__.py +43 -0
- codezoom/extractors/python/__init__.py +0 -0
- codezoom/extractors/python/ast_symbols.py +120 -0
- codezoom/extractors/python/module_hierarchy.py +171 -0
- codezoom/extractors/python/package_deps.py +100 -0
- codezoom/model.py +46 -0
- codezoom/pipeline.py +96 -0
- codezoom/renderer/__init__.py +0 -0
- codezoom/renderer/html.py +64 -0
- codezoom/renderer/template.html +549 -0
- codezoom-0.1.0.dev0.dist-info/METADATA +127 -0
- codezoom-0.1.0.dev0.dist-info/RECORD +20 -0
- codezoom-0.1.0.dev0.dist-info/WHEEL +4 -0
- codezoom-0.1.0.dev0.dist-info/entry_points.txt +2 -0
codezoom/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""codezoom — Multi-level code structure explorer."""
|
codezoom/__main__.py
ADDED
codezoom/cli.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Command-line interface for codezoom."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from codezoom.pipeline import run
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main(argv: list[str] | None = None) -> None:
|
|
12
|
+
parser = argparse.ArgumentParser(
|
|
13
|
+
prog="codezoom",
|
|
14
|
+
description="Multi-level code structure explorer — interactive drill-down HTML visualizations.",
|
|
15
|
+
)
|
|
16
|
+
parser.add_argument(
|
|
17
|
+
"project_dir",
|
|
18
|
+
type=Path,
|
|
19
|
+
help="Path to the project to visualize",
|
|
20
|
+
)
|
|
21
|
+
parser.add_argument(
|
|
22
|
+
"-o",
|
|
23
|
+
"--output",
|
|
24
|
+
type=Path,
|
|
25
|
+
default=None,
|
|
26
|
+
help="Output HTML file path (default: <project>_deps.html)",
|
|
27
|
+
)
|
|
28
|
+
parser.add_argument(
|
|
29
|
+
"--name",
|
|
30
|
+
default=None,
|
|
31
|
+
help="Project display name (default: auto-detect from pyproject.toml)",
|
|
32
|
+
)
|
|
33
|
+
parser.add_argument(
|
|
34
|
+
"--open",
|
|
35
|
+
action="store_true",
|
|
36
|
+
dest="open_browser",
|
|
37
|
+
help="Open the generated HTML in a browser",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
args = parser.parse_args(argv)
|
|
41
|
+
|
|
42
|
+
run(
|
|
43
|
+
args.project_dir,
|
|
44
|
+
output=args.output,
|
|
45
|
+
name=args.name,
|
|
46
|
+
open_browser=args.open_browser,
|
|
47
|
+
)
|
codezoom/detect.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Auto-detect project type and return appropriate extractors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from codezoom.extractors.base import Extractor
|
|
9
|
+
from codezoom.extractors.python.ast_symbols import AstSymbolsExtractor
|
|
10
|
+
from codezoom.extractors.python.module_hierarchy import ModuleHierarchyExtractor
|
|
11
|
+
from codezoom.extractors.python.package_deps import PackageDepsExtractor
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def detect_extractors(project_dir: Path, project_name: str) -> list[Extractor]:
|
|
15
|
+
"""Return an ordered list of extractors applicable to *project_dir*."""
|
|
16
|
+
extractors: list[Extractor] = []
|
|
17
|
+
|
|
18
|
+
if (project_dir / "pyproject.toml").exists():
|
|
19
|
+
# Read optional codezoom config
|
|
20
|
+
exclude = _read_config_exclude(project_dir, project_name)
|
|
21
|
+
extractors.append(PackageDepsExtractor())
|
|
22
|
+
extractors.append(ModuleHierarchyExtractor(exclude=exclude))
|
|
23
|
+
extractors.append(AstSymbolsExtractor())
|
|
24
|
+
|
|
25
|
+
if (project_dir / "pom.xml").exists() or (project_dir / "build.gradle").exists():
|
|
26
|
+
print(
|
|
27
|
+
"Note: Java project detected — Java support coming soon.",
|
|
28
|
+
file=sys.stderr,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
return extractors
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _read_config_exclude(project_dir: Path, project_name: str) -> list[str] | None:
|
|
35
|
+
"""Read pydeps exclude list from .codezoom.toml or pyproject.toml."""
|
|
36
|
+
import tomllib
|
|
37
|
+
|
|
38
|
+
# Try .codezoom.toml first
|
|
39
|
+
codezoom_toml = project_dir / ".codezoom.toml"
|
|
40
|
+
if codezoom_toml.exists():
|
|
41
|
+
try:
|
|
42
|
+
with open(codezoom_toml, "rb") as f:
|
|
43
|
+
data = tomllib.load(f)
|
|
44
|
+
return data.get("codezoom", {}).get("exclude", None)
|
|
45
|
+
except Exception:
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
# Fall back to [tool.codezoom] in pyproject.toml
|
|
49
|
+
pyproject = project_dir / "pyproject.toml"
|
|
50
|
+
if pyproject.exists():
|
|
51
|
+
try:
|
|
52
|
+
with open(pyproject, "rb") as f:
|
|
53
|
+
data = tomllib.load(f)
|
|
54
|
+
return data.get("tool", {}).get("codezoom", {}).get("exclude", None)
|
|
55
|
+
except Exception:
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
return None
|
|
File without changes
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Extractor protocol — all extractors conform to this interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Protocol
|
|
7
|
+
|
|
8
|
+
from codezoom.model import ProjectGraph
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Extractor(Protocol):
|
|
12
|
+
"""Protocol for project structure extractors."""
|
|
13
|
+
|
|
14
|
+
def can_handle(self, project_dir: Path) -> bool:
|
|
15
|
+
"""Return True if this extractor applies to the given project."""
|
|
16
|
+
...
|
|
17
|
+
|
|
18
|
+
def extract(self, project_dir: Path, graph: ProjectGraph) -> None:
|
|
19
|
+
"""Populate *graph* with data extracted from *project_dir*."""
|
|
20
|
+
...
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Java extractors — stubs for future implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from codezoom.model import ProjectGraph
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class JavaMavenDeps:
|
|
11
|
+
"""Extract dependencies from pom.xml / build.gradle. (Not yet implemented.)"""
|
|
12
|
+
|
|
13
|
+
def can_handle(self, project_dir: Path) -> bool:
|
|
14
|
+
return (project_dir / "pom.xml").exists() or (
|
|
15
|
+
project_dir / "build.gradle"
|
|
16
|
+
).exists()
|
|
17
|
+
|
|
18
|
+
def extract(self, project_dir: Path, graph: ProjectGraph) -> None:
|
|
19
|
+
pass # TODO: parse pom.xml / build.gradle
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class JavaPackageHierarchy:
|
|
23
|
+
"""Extract Java package/class hierarchy. (Not yet implemented.)"""
|
|
24
|
+
|
|
25
|
+
def can_handle(self, project_dir: Path) -> bool:
|
|
26
|
+
return (project_dir / "pom.xml").exists() or (
|
|
27
|
+
project_dir / "build.gradle"
|
|
28
|
+
).exists()
|
|
29
|
+
|
|
30
|
+
def extract(self, project_dir: Path, graph: ProjectGraph) -> None:
|
|
31
|
+
pass # TODO: walk src/main/java tree
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class JavaAstSymbols:
|
|
35
|
+
"""Extract methods/fields from Java classes. (Not yet implemented.)"""
|
|
36
|
+
|
|
37
|
+
def can_handle(self, project_dir: Path) -> bool:
|
|
38
|
+
return (project_dir / "pom.xml").exists() or (
|
|
39
|
+
project_dir / "build.gradle"
|
|
40
|
+
).exists()
|
|
41
|
+
|
|
42
|
+
def extract(self, project_dir: Path, graph: ProjectGraph) -> None:
|
|
43
|
+
pass # TODO: parse Java AST
|
|
File without changes
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Extract functions, classes, and methods from Python source via AST."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from codezoom.model import NodeData, ProjectGraph, SymbolData
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AstSymbolsExtractor:
|
|
12
|
+
"""Populate hierarchy leaf nodes with symbol (function/class/method) data."""
|
|
13
|
+
|
|
14
|
+
def can_handle(self, project_dir: Path) -> bool:
|
|
15
|
+
return (project_dir / "pyproject.toml").exists()
|
|
16
|
+
|
|
17
|
+
def extract(self, project_dir: Path, graph: ProjectGraph) -> None:
|
|
18
|
+
src_dir = _find_source_dir(project_dir, graph.root_node_id)
|
|
19
|
+
if src_dir is None:
|
|
20
|
+
return
|
|
21
|
+
|
|
22
|
+
for py_file in src_dir.rglob("*.py"):
|
|
23
|
+
if py_file.name == "__init__.py":
|
|
24
|
+
continue
|
|
25
|
+
|
|
26
|
+
relative = py_file.relative_to(src_dir.parent)
|
|
27
|
+
module_name = (
|
|
28
|
+
str(relative).replace("/", ".").replace("\\", ".").removesuffix(".py")
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
symbols = _extract_symbols(py_file)
|
|
32
|
+
if symbols:
|
|
33
|
+
node = graph.hierarchy.get(module_name)
|
|
34
|
+
if node is None:
|
|
35
|
+
node = NodeData()
|
|
36
|
+
graph.hierarchy[module_name] = node
|
|
37
|
+
node.symbols = symbols
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _find_source_dir(project_dir: Path, root_node_id: str) -> Path | None:
|
|
41
|
+
candidate = project_dir / "src" / root_node_id
|
|
42
|
+
if candidate.is_dir():
|
|
43
|
+
return candidate
|
|
44
|
+
candidate = project_dir / root_node_id
|
|
45
|
+
if candidate.is_dir():
|
|
46
|
+
return candidate
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class _CallExtractor(ast.NodeVisitor):
|
|
51
|
+
"""Collect names called within a function/method body."""
|
|
52
|
+
|
|
53
|
+
def __init__(self) -> None:
|
|
54
|
+
self.called_names: set[str] = set()
|
|
55
|
+
|
|
56
|
+
def visit_Call(self, node: ast.Call) -> None:
|
|
57
|
+
if isinstance(node.func, ast.Name):
|
|
58
|
+
self.called_names.add(node.func.id)
|
|
59
|
+
elif isinstance(node.func, ast.Attribute):
|
|
60
|
+
if isinstance(node.func.value, ast.Name):
|
|
61
|
+
self.called_names.add(node.func.attr)
|
|
62
|
+
self.generic_visit(node)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _extract_symbols(file_path: Path) -> dict[str, SymbolData] | None:
|
|
66
|
+
"""Return symbol data for top-level functions and classes in *file_path*."""
|
|
67
|
+
try:
|
|
68
|
+
tree = ast.parse(file_path.read_text())
|
|
69
|
+
except Exception:
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
results: dict[str, SymbolData] = {}
|
|
73
|
+
|
|
74
|
+
for node in tree.body:
|
|
75
|
+
if isinstance(node, ast.FunctionDef):
|
|
76
|
+
ext = _CallExtractor()
|
|
77
|
+
ext.visit(node)
|
|
78
|
+
results[node.name] = SymbolData(
|
|
79
|
+
name=node.name,
|
|
80
|
+
kind="function",
|
|
81
|
+
line=node.lineno,
|
|
82
|
+
calls=sorted(ext.called_names),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
elif isinstance(node, ast.ClassDef):
|
|
86
|
+
bases: list[str] = []
|
|
87
|
+
for base in node.bases:
|
|
88
|
+
if isinstance(base, ast.Name):
|
|
89
|
+
bases.append(base.id)
|
|
90
|
+
elif isinstance(base, ast.Attribute):
|
|
91
|
+
bases.append(base.attr)
|
|
92
|
+
|
|
93
|
+
methods: dict[str, SymbolData] = {}
|
|
94
|
+
for item in node.body:
|
|
95
|
+
if isinstance(item, ast.FunctionDef):
|
|
96
|
+
ext = _CallExtractor()
|
|
97
|
+
ext.visit(item)
|
|
98
|
+
methods[item.name] = SymbolData(
|
|
99
|
+
name=item.name,
|
|
100
|
+
kind="method",
|
|
101
|
+
line=item.lineno,
|
|
102
|
+
calls=sorted(ext.called_names),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# Class-level calls (decorators, class-var assignments, etc.)
|
|
106
|
+
ext = _CallExtractor()
|
|
107
|
+
for item in node.body:
|
|
108
|
+
if not isinstance(item, ast.FunctionDef):
|
|
109
|
+
ext.visit(item)
|
|
110
|
+
|
|
111
|
+
results[node.name] = SymbolData(
|
|
112
|
+
name=node.name,
|
|
113
|
+
kind="class",
|
|
114
|
+
line=node.lineno,
|
|
115
|
+
calls=sorted(ext.called_names),
|
|
116
|
+
inherits=bases,
|
|
117
|
+
children=methods,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
return results or None
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Extract module hierarchy via pydeps or simple file-walk fallback."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from collections import defaultdict
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from codezoom.model import NodeData, ProjectGraph
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ModuleHierarchyExtractor:
|
|
16
|
+
"""Populate hierarchy with package/module tree and inter-module imports."""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
*,
|
|
21
|
+
exclude: list[str] | None = None,
|
|
22
|
+
):
|
|
23
|
+
self._exclude = exclude
|
|
24
|
+
|
|
25
|
+
def can_handle(self, project_dir: Path) -> bool:
|
|
26
|
+
return (project_dir / "pyproject.toml").exists()
|
|
27
|
+
|
|
28
|
+
def extract(self, project_dir: Path, graph: ProjectGraph) -> None:
|
|
29
|
+
src_dir = _find_source_dir(project_dir, graph.root_node_id)
|
|
30
|
+
if src_dir is None:
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
deps = _run_pydeps(project_dir, src_dir, graph.root_node_id, self._exclude)
|
|
34
|
+
if deps is None:
|
|
35
|
+
# Fallback: build hierarchy from file tree only (no import edges)
|
|
36
|
+
deps = _build_deps_from_files(src_dir, graph.root_node_id)
|
|
37
|
+
|
|
38
|
+
_build_hierarchical_data(deps, graph)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _find_source_dir(project_dir: Path, root_node_id: str) -> Path | None:
|
|
42
|
+
"""Locate the Python package directory inside the project."""
|
|
43
|
+
# Try src-layout first
|
|
44
|
+
candidate = project_dir / "src" / root_node_id
|
|
45
|
+
if candidate.is_dir():
|
|
46
|
+
return candidate
|
|
47
|
+
# Try flat layout
|
|
48
|
+
candidate = project_dir / root_node_id
|
|
49
|
+
if candidate.is_dir():
|
|
50
|
+
return candidate
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _run_pydeps(
|
|
55
|
+
project_dir: Path,
|
|
56
|
+
src_dir: Path,
|
|
57
|
+
root_node_id: str,
|
|
58
|
+
exclude: list[str] | None,
|
|
59
|
+
) -> dict | None:
|
|
60
|
+
"""Run pydeps and return the JSON dict, or None on failure."""
|
|
61
|
+
pydeps_path = shutil.which("pydeps")
|
|
62
|
+
if not pydeps_path:
|
|
63
|
+
print(
|
|
64
|
+
"Warning: pydeps not found (install with `pip install pydeps`). "
|
|
65
|
+
"Falling back to file-based hierarchy.",
|
|
66
|
+
file=sys.stderr,
|
|
67
|
+
)
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
cmd: list[str] = [
|
|
71
|
+
pydeps_path,
|
|
72
|
+
str(src_dir),
|
|
73
|
+
"--show-deps",
|
|
74
|
+
"--no-show",
|
|
75
|
+
"--no-output",
|
|
76
|
+
]
|
|
77
|
+
if exclude:
|
|
78
|
+
cmd.extend(["-xx"] + exclude)
|
|
79
|
+
|
|
80
|
+
result = subprocess.run(
|
|
81
|
+
cmd,
|
|
82
|
+
capture_output=True,
|
|
83
|
+
text=True,
|
|
84
|
+
cwd=str(project_dir),
|
|
85
|
+
)
|
|
86
|
+
if result.returncode != 0:
|
|
87
|
+
print(f"Warning: pydeps failed: {result.stderr}", file=sys.stderr)
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
return json.loads(result.stdout)
|
|
92
|
+
except json.JSONDecodeError as e:
|
|
93
|
+
print(f"Warning: pydeps JSON parse error: {e}", file=sys.stderr)
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _build_deps_from_files(src_dir: Path, root_node_id: str) -> dict:
|
|
98
|
+
"""Build a minimal dep dict from the file tree (no import edges)."""
|
|
99
|
+
deps: dict[str, dict] = {}
|
|
100
|
+
for py_file in src_dir.rglob("*.py"):
|
|
101
|
+
if py_file.name == "__init__.py":
|
|
102
|
+
continue
|
|
103
|
+
relative = py_file.relative_to(src_dir.parent)
|
|
104
|
+
module_name = (
|
|
105
|
+
str(relative).replace("/", ".").replace("\\", ".").removesuffix(".py")
|
|
106
|
+
)
|
|
107
|
+
deps[module_name] = {"imports": []}
|
|
108
|
+
return deps
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _build_hierarchical_data(deps: dict, graph: ProjectGraph) -> None:
|
|
112
|
+
"""Build the hierarchy inside *graph* from pydeps output."""
|
|
113
|
+
root_id = graph.root_node_id
|
|
114
|
+
|
|
115
|
+
hierarchy: dict[str, dict[str, set]] = defaultdict(
|
|
116
|
+
lambda: {"children": set(), "imports_from": set(), "imports_to": set()}
|
|
117
|
+
)
|
|
118
|
+
hierarchy[root_id] = {"children": set(), "imports_from": set(), "imports_to": set()}
|
|
119
|
+
|
|
120
|
+
for module_name, info in deps.items():
|
|
121
|
+
if module_name == "__main__":
|
|
122
|
+
continue
|
|
123
|
+
|
|
124
|
+
parts = module_name.split(".")
|
|
125
|
+
|
|
126
|
+
# Build parent→child edges
|
|
127
|
+
current = root_id
|
|
128
|
+
for i, _part in enumerate(parts[1:], 1):
|
|
129
|
+
child_name = ".".join(parts[: i + 1])
|
|
130
|
+
hierarchy[current]["children"].add(child_name)
|
|
131
|
+
hierarchy[child_name] # ensure exists
|
|
132
|
+
current = child_name
|
|
133
|
+
|
|
134
|
+
# Track module-level imports
|
|
135
|
+
for imported in info.get("imports", []):
|
|
136
|
+
if imported != "__main__":
|
|
137
|
+
hierarchy[module_name]["imports_to"].add(imported)
|
|
138
|
+
hierarchy[imported]["imports_from"].add(module_name)
|
|
139
|
+
|
|
140
|
+
# Aggregate imports for package nodes
|
|
141
|
+
def aggregate_imports(node_id: str) -> tuple[set[str], set[str]]:
|
|
142
|
+
node = hierarchy[node_id]
|
|
143
|
+
if not node["children"]:
|
|
144
|
+
node["imports_to"] = {
|
|
145
|
+
imp for imp in node["imports_to"] if not imp.startswith(node_id)
|
|
146
|
+
}
|
|
147
|
+
return node["imports_to"], node["imports_from"]
|
|
148
|
+
|
|
149
|
+
all_to = set(node["imports_to"])
|
|
150
|
+
all_from = set(node["imports_from"])
|
|
151
|
+
for child_id in node["children"]:
|
|
152
|
+
child_to, child_from = aggregate_imports(child_id)
|
|
153
|
+
all_to.update(child_to)
|
|
154
|
+
all_from.update(child_from)
|
|
155
|
+
|
|
156
|
+
all_to = {imp for imp in all_to if not imp.startswith(node_id)}
|
|
157
|
+
hierarchy[node_id]["imports_to"] = all_to
|
|
158
|
+
hierarchy[node_id]["imports_from"] = all_from
|
|
159
|
+
return all_to, all_from
|
|
160
|
+
|
|
161
|
+
aggregate_imports(root_id)
|
|
162
|
+
|
|
163
|
+
# Write into graph.hierarchy (preserving any existing symbols data)
|
|
164
|
+
for node_id, raw in hierarchy.items():
|
|
165
|
+
existing = graph.hierarchy.get(node_id)
|
|
166
|
+
graph.hierarchy[node_id] = NodeData(
|
|
167
|
+
children=sorted(raw["children"]),
|
|
168
|
+
imports_to=sorted(raw["imports_to"]),
|
|
169
|
+
imports_from=sorted(raw["imports_from"]),
|
|
170
|
+
symbols=existing.symbols if existing else None,
|
|
171
|
+
)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Extract external package dependencies from pyproject.toml + uv.lock."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from codezoom.model import ExternalDep, ProjectGraph
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PackageDepsExtractor:
|
|
12
|
+
"""Populate external_deps and external_deps_graph from project metadata."""
|
|
13
|
+
|
|
14
|
+
def can_handle(self, project_dir: Path) -> bool:
|
|
15
|
+
return (project_dir / "pyproject.toml").exists()
|
|
16
|
+
|
|
17
|
+
def extract(self, project_dir: Path, graph: ProjectGraph) -> None:
|
|
18
|
+
direct_deps, dep_graph = _extract_python_dependencies(project_dir)
|
|
19
|
+
|
|
20
|
+
# Collect all deps (direct + transitive)
|
|
21
|
+
all_deps: set[str] = set(direct_deps)
|
|
22
|
+
visited: set[str] = set()
|
|
23
|
+
|
|
24
|
+
def collect_transitive(pkg_name: str) -> None:
|
|
25
|
+
if pkg_name in visited:
|
|
26
|
+
return
|
|
27
|
+
visited.add(pkg_name)
|
|
28
|
+
if pkg_name in dep_graph:
|
|
29
|
+
for dep in dep_graph[pkg_name]:
|
|
30
|
+
all_deps.add(dep)
|
|
31
|
+
collect_transitive(dep)
|
|
32
|
+
|
|
33
|
+
for dep in direct_deps:
|
|
34
|
+
collect_transitive(dep)
|
|
35
|
+
|
|
36
|
+
direct_set = set(direct_deps)
|
|
37
|
+
graph.external_deps = [
|
|
38
|
+
ExternalDep(name=d, is_direct=(d in direct_set)) for d in sorted(all_deps)
|
|
39
|
+
]
|
|
40
|
+
graph.external_deps_graph = dep_graph
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _extract_python_dependencies(
|
|
44
|
+
project_root: Path,
|
|
45
|
+
) -> tuple[list[str], dict[str, list[str]]]:
|
|
46
|
+
"""Return (direct_deps, dependency_graph) from pyproject.toml + uv.lock."""
|
|
47
|
+
import tomllib
|
|
48
|
+
|
|
49
|
+
pyproject_path = project_root / "pyproject.toml"
|
|
50
|
+
if not pyproject_path.exists():
|
|
51
|
+
return [], {}
|
|
52
|
+
|
|
53
|
+
# --- direct deps from pyproject.toml ---
|
|
54
|
+
direct_deps: list[str] = []
|
|
55
|
+
try:
|
|
56
|
+
with open(pyproject_path, "rb") as f:
|
|
57
|
+
data = tomllib.load(f)
|
|
58
|
+
|
|
59
|
+
for dep in data.get("project", {}).get("dependencies", []):
|
|
60
|
+
pkg_name = (
|
|
61
|
+
dep.split("[")[0]
|
|
62
|
+
.split(">")[0]
|
|
63
|
+
.split("<")[0]
|
|
64
|
+
.split("=")[0]
|
|
65
|
+
.split(";")[0]
|
|
66
|
+
.strip()
|
|
67
|
+
)
|
|
68
|
+
if pkg_name:
|
|
69
|
+
direct_deps.append(pkg_name.lower())
|
|
70
|
+
except Exception as e:
|
|
71
|
+
print(f"Warning: Could not parse pyproject.toml: {e}", file=sys.stderr)
|
|
72
|
+
|
|
73
|
+
# --- transitive deps from uv.lock ---
|
|
74
|
+
dep_graph: dict[str, list[str]] = {}
|
|
75
|
+
uv_lock_path = project_root / "uv.lock"
|
|
76
|
+
if uv_lock_path.exists():
|
|
77
|
+
try:
|
|
78
|
+
with open(uv_lock_path, "rb") as f:
|
|
79
|
+
lock_data = tomllib.load(f)
|
|
80
|
+
|
|
81
|
+
packages = lock_data.get("package", [])
|
|
82
|
+
if isinstance(packages, list):
|
|
83
|
+
for pkg_info in packages:
|
|
84
|
+
pkg_name = pkg_info.get("name", "").lower()
|
|
85
|
+
if not pkg_name:
|
|
86
|
+
continue
|
|
87
|
+
pkg_deps: list[str] = []
|
|
88
|
+
dependencies = pkg_info.get("dependencies", [])
|
|
89
|
+
if isinstance(dependencies, list):
|
|
90
|
+
for dep in dependencies:
|
|
91
|
+
if isinstance(dep, dict):
|
|
92
|
+
dep_name = dep.get("name", "").lower()
|
|
93
|
+
if dep_name and dep_name not in pkg_deps:
|
|
94
|
+
pkg_deps.append(dep_name)
|
|
95
|
+
if pkg_deps:
|
|
96
|
+
dep_graph[pkg_name] = pkg_deps
|
|
97
|
+
except Exception as e:
|
|
98
|
+
print(f"Warning: Could not parse uv.lock: {e}", file=sys.stderr)
|
|
99
|
+
|
|
100
|
+
return sorted(set(direct_deps)), dep_graph
|
codezoom/model.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Language-agnostic data model for project structure graphs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class SymbolData:
|
|
10
|
+
"""A function, class, or method within a module."""
|
|
11
|
+
|
|
12
|
+
name: str
|
|
13
|
+
kind: str # "function", "class", "method"
|
|
14
|
+
line: int | None = None
|
|
15
|
+
calls: list[str] = field(default_factory=list)
|
|
16
|
+
inherits: list[str] = field(default_factory=list)
|
|
17
|
+
children: dict[str, SymbolData] = field(default_factory=dict)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class NodeData:
|
|
22
|
+
"""A node in the project hierarchy (package, module, etc.)."""
|
|
23
|
+
|
|
24
|
+
children: list[str] = field(default_factory=list)
|
|
25
|
+
imports_to: list[str] = field(default_factory=list)
|
|
26
|
+
imports_from: list[str] = field(default_factory=list)
|
|
27
|
+
symbols: dict[str, SymbolData] | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class ExternalDep:
|
|
32
|
+
"""An external package dependency."""
|
|
33
|
+
|
|
34
|
+
name: str
|
|
35
|
+
is_direct: bool
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class ProjectGraph:
|
|
40
|
+
"""Complete project structure produced by extractors."""
|
|
41
|
+
|
|
42
|
+
project_name: str
|
|
43
|
+
root_node_id: str
|
|
44
|
+
hierarchy: dict[str, NodeData] = field(default_factory=dict)
|
|
45
|
+
external_deps: list[ExternalDep] = field(default_factory=list)
|
|
46
|
+
external_deps_graph: dict[str, list[str]] = field(default_factory=dict)
|