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 ADDED
@@ -0,0 +1 @@
1
+ """codezoom — Multi-level code structure explorer."""
codezoom/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow running as `python -m codezoom`."""
2
+
3
+ from codezoom.cli import main
4
+
5
+ main()
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)