depsgraph 0.1.3__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.
depsgraph/__init__.py ADDED
@@ -0,0 +1,356 @@
1
+ """Project-local dependency graph for a single Python file.
2
+
3
+ Given a project directory and a target Python file, this module prints the
4
+ project-internal import dependency graph (files only) as an ASCII tree and
5
+ optionally renders it as a Mermaid mindmap.
6
+
7
+ It only reports dependencies that resolve to Python files within the given
8
+ project directory. Stdlib and third-party imports are ignored by default.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import ast
15
+ from pathlib import Path
16
+
17
+
18
+ DEFAULT_IGNORED_DIR_NAMES: frozenset[str] = frozenset(
19
+ {
20
+ ".git",
21
+ ".hg",
22
+ ".mypy_cache",
23
+ ".pytest_cache",
24
+ ".ruff_cache",
25
+ ".tox",
26
+ ".venv",
27
+ "__pycache__",
28
+ "build",
29
+ "dist",
30
+ "site-packages",
31
+ }
32
+ )
33
+
34
+
35
+ def _parse_args(argv:list[str]|None=None) -> argparse.Namespace:
36
+ parser = argparse.ArgumentParser(description=("Show project-internal Python import dependencies for a file, as an ASCII graph and optionally a Mermaid mindmap."),)
37
+ parser.add_argument("target", help="Target Python file path (absolute or relative to project dir).")
38
+ parser.add_argument("--project-dir", default=".", help="Project directory to scan (default: current directory).")
39
+ parser.add_argument("--max-depth", type=int, default=50, help="Maximum recursion depth (default: 50).")
40
+ parser.add_argument("--show-missing", action="store_true", help="Also list imports that could not be resolved to project files.",)
41
+ parser.add_argument("--no-mermaid", action="store_true", help="Do not generate Mermaid mindmap files.")
42
+ parser.add_argument("--mermaid-mmd", default=None, help="Output path for Mermaid .mmd (default: <project-dir>/deps_graph.mmd).")
43
+ parser.add_argument("--mermaid-html", default=None, help="Output path for Mermaid HTML (default: <project-dir>/deps_graph.html).")
44
+ parser.add_argument("--ignored-dir", action="append", default=[], help=("Directory name to ignore (can be repeated). " "Defaults include .venv, .git, __pycache__, dist, build."),)
45
+ return parser.parse_args(argv)
46
+
47
+
48
+ def _is_ignored_dir_name(dir_name: str, extra_ignored: frozenset[str]) -> bool:
49
+ return dir_name in DEFAULT_IGNORED_DIR_NAMES or dir_name in extra_ignored
50
+
51
+
52
+ def _iter_python_files(project_dir: Path, extra_ignored: frozenset[str]) -> list[Path]:
53
+ files: list[Path] = []
54
+ for path in project_dir.rglob("*.py"):
55
+ if not path.is_file():
56
+ continue
57
+ rel = path.relative_to(project_dir)
58
+ if any(_is_ignored_dir_name(part, extra_ignored) for part in rel.parts[:-1]):
59
+ continue
60
+ files.append(path)
61
+ files.sort()
62
+ return files
63
+
64
+
65
+ def _has_init_file(directory: Path) -> bool:
66
+ return (directory / "__init__.py").is_file()
67
+
68
+
69
+ def _compute_module_name(project_dir: Path, file_path: Path) -> str | None:
70
+ """Best-effort module name for a file based on __init__.py packages.
71
+
72
+ Returns None if the file isn't under any importable package structure.
73
+ For top-level modules (project_dir/foo.py) it returns "foo".
74
+ """
75
+ rel = file_path.relative_to(project_dir)
76
+ if rel.suffix != ".py":
77
+ return None
78
+ if len(rel.parts) == 1:
79
+ return rel.stem
80
+ parts = list(rel.parts)
81
+ filename = parts.pop()
82
+ if filename == "__init__.py":
83
+ module_parts = parts
84
+ else:
85
+ module_parts = parts + [Path(filename).stem]
86
+ current = project_dir
87
+ package_parts: list[str] = []
88
+ for part in module_parts[:-1]:
89
+ current = current / part
90
+ if not _has_init_file(current):
91
+ return None
92
+ package_parts.append(part)
93
+ return ".".join(package_parts + [module_parts[-1]])
94
+
95
+
96
+ def _compute_parent_package(project_dir: Path, file_path: Path) -> list[str]:
97
+ """Return the dotted package parts for the file's parent package."""
98
+ rel = file_path.relative_to(project_dir)
99
+ if len(rel.parts) == 1:
100
+ return []
101
+ directory_parts = list(rel.parts[:-1])
102
+ current = project_dir
103
+ package_parts: list[str] = []
104
+ for part in directory_parts:
105
+ current = current / part
106
+ if not _has_init_file(current):
107
+ break
108
+ package_parts.append(part)
109
+ return package_parts
110
+
111
+
112
+ def _build_module_index(project_dir: Path, python_files: list[Path]) -> dict[str, Path]:
113
+ index: dict[str, Path] = {}
114
+ for file_path in python_files:
115
+ module_name = _compute_module_name(project_dir, file_path)
116
+ if module_name is None:
117
+ continue
118
+ index.setdefault(module_name, file_path)
119
+ return index
120
+
121
+
122
+ def _parse_imports(file_path: Path) -> tuple[list[ast.AST], str | None]:
123
+ try:
124
+ source = file_path.read_text(encoding="utf-8")
125
+ except (OSError, UnicodeError) as exc:
126
+ return [], f"read error: {exc}"
127
+ try:
128
+ tree = ast.parse(source, filename=str(file_path))
129
+ except SyntaxError as exc:
130
+ return [], f"syntax error: {exc.msg} (line {exc.lineno})"
131
+ nodes: list[ast.AST] = []
132
+ for node in ast.walk(tree):
133
+ if isinstance(node, (ast.Import, ast.ImportFrom)):
134
+ nodes.append(node)
135
+ return nodes, None
136
+
137
+
138
+ def _resolve_from_import(*, current_package: list[str], module: str | None, level: int, imported_names: list[str],) -> list[str]:
139
+ if level < 0:
140
+ level = 0
141
+ if level == 0:
142
+ base_parts = []
143
+ else:
144
+ up = level - 1
145
+ if up >= len(current_package):
146
+ base_parts = []
147
+ else:
148
+ base_parts = current_package[: len(current_package) - up]
149
+ module_parts = module.split(".") if module else []
150
+ base_module_parts = base_parts + module_parts
151
+ base_module = ".".join(base_module_parts)
152
+ candidates: list[str] = []
153
+ if base_module:
154
+ candidates.append(base_module)
155
+ for name in imported_names:
156
+ if not name or name == "*":
157
+ continue
158
+ if base_module:
159
+ candidates.append(f"{base_module}.{name}")
160
+ else:
161
+ candidates.append(name)
162
+ return candidates
163
+
164
+
165
+ def _resolve_import_candidates(*, project_dir: Path, file_path: Path, import_node: ast.AST,) -> list[str]:
166
+ current_package = _compute_parent_package(project_dir, file_path)
167
+ if isinstance(import_node, ast.Import):
168
+ names: list[str] = []
169
+ for alias in import_node.names:
170
+ if alias.name:
171
+ names.append(alias.name)
172
+ return names
173
+ if isinstance(import_node, ast.ImportFrom):
174
+ imported_names = [alias.name for alias in import_node.names if alias.name]
175
+ return _resolve_from_import(current_package=current_package, module=import_node.module, level=import_node.level, imported_names=imported_names,)
176
+ return []
177
+
178
+
179
+ def _direct_project_deps_for_file(*, project_dir: Path, file_path: Path, module_index: dict[str, Path],) -> tuple[list[Path], list[str], str | None]:
180
+ nodes, error = _parse_imports(file_path)
181
+ if error is not None:
182
+ return [], [], error
183
+ deps: dict[Path, None] = {}
184
+ missing: dict[str, None] = {}
185
+ for node in nodes:
186
+ candidates = _resolve_import_candidates(project_dir=project_dir, file_path=file_path, import_node=node)
187
+ matched_any = False
188
+ for mod in candidates:
189
+ target = module_index.get(mod)
190
+ if target is None:
191
+ continue
192
+ deps[target] = None
193
+ matched_any = True
194
+ if not matched_any:
195
+ for mod in candidates:
196
+ missing[mod] = None
197
+ dep_files = sorted(deps.keys(), key=lambda p: str(p))
198
+ missing_imports = sorted(missing.keys())
199
+ return dep_files, missing_imports, None
200
+
201
+
202
+ def _build_dependency_graph(*, project_dir: Path, root_file: Path, module_index: dict[str, Path], max_depth: int,) -> tuple[dict[Path, list[Path]], dict[Path, list[str]], dict[Path, str]]:
203
+ edges: dict[Path, list[Path]] = {}
204
+ missing_imports: dict[Path, list[str]] = {}
205
+ file_errors: dict[Path, str] = {}
206
+ to_visit: list[tuple[Path, int]] = [(root_file, 0)]
207
+ visited: set[Path] = set()
208
+ while to_visit:
209
+ file_path, depth = to_visit.pop()
210
+ if file_path in visited:
211
+ continue
212
+ visited.add(file_path)
213
+ if depth > max_depth:
214
+ continue
215
+ deps, missing, error = _direct_project_deps_for_file(project_dir=project_dir, file_path=file_path, module_index=module_index,)
216
+ if error is not None:
217
+ file_errors[file_path] = error
218
+ edges[file_path] = []
219
+ continue
220
+ edges[file_path] = deps
221
+ if missing:
222
+ missing_imports[file_path] = missing
223
+ next_depth = depth + 1
224
+ if next_depth <= max_depth:
225
+ for dep in deps:
226
+ if dep not in visited:
227
+ to_visit.append((dep, next_depth))
228
+ return edges, missing_imports, file_errors
229
+
230
+
231
+ def _format_rel(project_dir: Path, file_path: Path) -> str:
232
+ try:
233
+ return str(file_path.relative_to(project_dir))
234
+ except ValueError:
235
+ return str(file_path)
236
+
237
+
238
+ def _render_ascii_tree(*, project_dir: Path, edges: dict[Path, list[Path]], root_file: Path, max_depth: int,) -> str:
239
+ lines: list[str] = []
240
+ def walk(node: Path, prefix: str, depth: int, stack: set[Path]) -> None:
241
+ if depth > max_depth:
242
+ return
243
+ deps = edges.get(node, [])
244
+ for i, dep in enumerate(deps):
245
+ is_last = i == (len(deps) - 1)
246
+ connector = "+-- "
247
+ line_prefix = prefix + connector
248
+ label = _format_rel(project_dir, dep)
249
+ if dep in stack:
250
+ lines.append(f"{line_prefix}{label} (cycle)")
251
+ continue
252
+ lines.append(f"{line_prefix}{label}")
253
+ child_prefix = prefix + ("| " if not is_last else " ")
254
+ stack.add(dep)
255
+ walk(dep, child_prefix, depth + 1, stack)
256
+ stack.remove(dep)
257
+ lines.append(_format_rel(project_dir, root_file))
258
+ walk(root_file, "", 0, {root_file})
259
+ return "\n".join(lines)
260
+
261
+
262
+ def _render_mermaid_mindmap(*, project_dir: Path, edges: dict[Path, list[Path]], root_file: Path, max_depth: int,) -> str:
263
+ lines: list[str] = ["mindmap"]
264
+ root_label = _format_rel(project_dir, root_file)
265
+ lines.append(f" root(({root_label}))")
266
+ def walk(node: Path, indent: str, depth: int, stack: set[Path]) -> None:
267
+ if depth > max_depth:
268
+ return
269
+ deps = edges.get(node, [])
270
+ for dep in deps:
271
+ label = _format_rel(project_dir, dep)
272
+ if dep in stack:
273
+ lines.append(f"{indent}{label} (cycle)")
274
+ continue
275
+ lines.append(f"{indent}{label}")
276
+ stack.add(dep)
277
+ walk(dep, indent + " ", depth + 1, stack)
278
+ stack.remove(dep)
279
+ walk(root_file, " ", 0, {root_file})
280
+ return "\n".join(lines)
281
+
282
+
283
+ def _write_text_file(path: Path, content: str) -> None:
284
+ path.write_text(content, encoding="utf-8")
285
+
286
+
287
+ def _render_mermaid_html(mermaid_text: str) -> str:
288
+ return (
289
+ "<!doctype html>\n"
290
+ "<html lang=\"en\">\n"
291
+ " <head>\n"
292
+ " <meta charset=\"utf-8\">\n"
293
+ " <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n"
294
+ " <title>Dependency Mindmap</title>\n"
295
+ " <style>\n"
296
+ " body { font-family: ui-sans-serif, system-ui, sans-serif; margin: 0; }\n"
297
+ " .wrap { padding: 24px; }\n"
298
+ " </style>\n"
299
+ " </head>\n"
300
+ " <body>\n"
301
+ " <div class=\"wrap\">\n"
302
+ " <div class=\"mermaid\">\n"
303
+ f"{mermaid_text}\n"
304
+ " </div>\n"
305
+ " </div>\n"
306
+ " <script type=\"module\">\n"
307
+ " import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs';\n"
308
+ " mermaid.initialize({ startOnLoad: true });\n"
309
+ " </script>\n"
310
+ " </body>\n"
311
+ "</html>\n"
312
+ )
313
+
314
+
315
+ def main(argv: list[str] | None = None) -> int:
316
+ args = _parse_args(argv)
317
+ project_dir = Path(args.project_dir).resolve()
318
+ extra_ignored = frozenset(args.ignored_dir)
319
+ target = Path(args.target)
320
+ if not target.is_absolute():
321
+ target = (project_dir / target).resolve()
322
+ if not project_dir.is_dir():
323
+ raise SystemExit(f"project dir not found: {project_dir}")
324
+ if not target.is_file():
325
+ raise SystemExit(f"target file not found: {target}")
326
+ if target.suffix != ".py":
327
+ raise SystemExit(f"target must be a .py file: {target}")
328
+ python_files = _iter_python_files(project_dir, extra_ignored)
329
+ module_index = _build_module_index(project_dir, python_files)
330
+ edges, missing_imports, file_errors = _build_dependency_graph(project_dir=project_dir, root_file=target, module_index=module_index, max_depth=max(0, int(args.max_depth)))
331
+ print(_render_ascii_tree(project_dir=project_dir, edges=edges, root_file=target, max_depth=args.max_depth))
332
+ if not args.no_mermaid:
333
+ mermaid_text = _render_mermaid_mindmap(project_dir=project_dir, edges=edges, root_file=target, max_depth=args.max_depth)
334
+ mmd_path = Path(args.mermaid_mmd) if args.mermaid_mmd else project_dir / "deps_graph.mmd"
335
+ html_path = Path(args.mermaid_html) if args.mermaid_html else project_dir / "deps_graph.html"
336
+ _write_text_file(mmd_path, mermaid_text)
337
+ _write_text_file(html_path, _render_mermaid_html(mermaid_text))
338
+ print(f"\nMermaid mindmap saved: {mmd_path}")
339
+ print(f"Open in browser: {html_path}")
340
+ if file_errors:
341
+ print("\nErrors:")
342
+ for file_path in sorted(file_errors, key=lambda p: str(p)):
343
+ print(f"- {_format_rel(project_dir, file_path)}: {file_errors[file_path]}")
344
+ if args.show_missing and missing_imports:
345
+ print("\nUnresolved imports (not in project):")
346
+ for file_path in sorted(missing_imports, key=lambda p: str(p)):
347
+ imports = missing_imports[file_path]
348
+ if not imports:
349
+ continue
350
+ joined = ", ".join(imports)
351
+ print(f"- {_format_rel(project_dir, file_path)}: {joined}")
352
+ return 0
353
+
354
+
355
+ if __name__ == "__main__":
356
+ raise SystemExit(main())
@@ -0,0 +1,50 @@
1
+ Metadata-Version: 2.4
2
+ Name: depsgraph
3
+ Version: 0.1.3
4
+ Summary: Visualize internal Python import dependencies
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: build>=1.4.0
8
+ Requires-Dist: twine>=6.2.0
9
+
10
+ depsgraph is a minimal command-line tool that shows which Python modules a single file depends on *within your project*. It ignores stdlib and third-party imports by default, so you see only your own code’s coupling.
11
+
12
+ ### Features
13
+ - Static analysis via AST (fast, no imports executed)
14
+ - ASCII tree printed to stdout
15
+ - Mermaid mindmap generated as `.mmd` and self-contained `.html`
16
+ - Configurable recursion depth and ignore patterns
17
+ - Detects import cycles and marks them
18
+
19
+ ### Install
20
+
21
+ pip install depsgraph
22
+
23
+ ### Usage
24
+
25
+ depsgraph path/to/file.py --project-dir /path/to/project
26
+
27
+ Example:
28
+
29
+ $ depsgraph src/handlers/api.py --project-dir .
30
+ src/handlers/api.py
31
+ +-- src/models/user.py
32
+ +-- src/utils/auth.py
33
+ | +-- src/config.py
34
+ +-- src/db/conn.py
35
+ +-- src/config.py
36
+
37
+ The command also creates `deps_graph.html` in the project directory; open it in a browser to view an interactive Mermaid mindmap.
38
+
39
+ ### Options
40
+ - --project-dir: root of the project to scan (default ".")
41
+ - --max-depth: limit graph depth (default 50)
42
+ - --show-missing: list unresolved imports (stdlib, missing files)
43
+ - --no-mermaid: skip Mermaid file generation
44
+ - --ignored-dir: repeatable, e.g. `--ignored-dir generated`
45
+
46
+ ### Requirements
47
+ Python 3.9+
48
+
49
+ ### License
50
+ MIT
@@ -0,0 +1,5 @@
1
+ depsgraph/__init__.py,sha256=q7ljLiUy6J88wAHt6BU5JCR9SBRfIrI6w2cb9X3Sgw4,14112
2
+ depsgraph-0.1.3.dist-info/METADATA,sha256=fzUXhOVWHE5NYmBhw8E_5YtKoYC2NoGMHKyyh8ptMf4,1461
3
+ depsgraph-0.1.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
4
+ depsgraph-0.1.3.dist-info/top_level.txt,sha256=sfdLqiIoO2_HgHCf5PE6ZgXJqKm6c_s4I1QUmytMluw,10
5
+ depsgraph-0.1.3.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ depsgraph