oct
Loading...
Searching...
No Matches
import_graph.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/deps/import_graph.py
4
5"""
6Purpose
7-------
8Build a project's dependency graph from what its modules actually
9``import``, and collapse it to package level for an overview chart.
10
11:mod:`oct.deps.oct_deps` builds a graph from the ``Dependencies`` section
12each module docstring declares. That is a contract, and a useful one, but
13a structural picture that can drift from the code is worse than none, so
14the overview chart on every subproject's ``dependencies`` page is derived
15from the AST instead: ``import x``, ``from x import y``, and relative
16imports resolved against the importing module's package. Only imports
17that resolve to a module inside the project become edges; third-party and
18standard-library imports are counted, not drawn.
19
20Responsibilities
21----------------
22- :func:`build_import_graph` - modules and edges from the AST, tests and
23 the standard exclusions skipped.
24- :func:`collapse_to_packages` - the package-level overview: one node per
25 top-level package, edges weighted by how many module-level edges they
26 stand for.
27- :func:`to_mermaid_flowchart` and :func:`to_dot` - render either level.
28
29Diagnostics
30-----------
31Domain: OCT-DEPS
32Levels:
33 L2 - build entry / exit with sizes
34 L3 - per-module resolution decisions
35 L4 - individual unresolved imports
36
37Contracts
38---------
39- Purely static: no project module is ever imported or executed.
40- A file that does not parse contributes no edges and is reported at L3;
41 it never aborts the graph, because a docs build must not die on one
42 broken module and the linter already blocks that module elsewhere.
43- Module keys are dotted paths relative to the project root, the same
44 scheme :func:`oct.deps.oct_deps.module_name_for` uses, so the two graphs
45 can be compared key for key.
46- ``__init__.py`` resolves to its package; an import of a package resolves
47 to that package node, not to every module in it.
48
49Dependencies
50------------
51- oct.core.diagnostics (_dbg structured logger)
52- oct.deps.oct_deps (module_name_for, _should_skip - shared key scheme)
53"""
54
55from __future__ import annotations
56
57import ast
58from collections import Counter
59from dataclasses import dataclass, field
60from pathlib import Path
61
62from oct.core.diagnostics import _dbg
63from oct.deps.oct_deps import _should_skip, module_name_for
64
65
66#: Directory names that hold tests. The docs tiers all leave tests out
67#: (ruled 2026-09-27), and a structural overview that included them would
68#: show every package depended on by "tests", which says nothing.
69_TEST_DIR_NAMES: frozenset[str] = frozenset({"tests", "test"})
70
71
72@dataclass
74 """The result of :func:`build_import_graph`.
75
76 Attributes
77 ----------
78 edges
79 ``module -> sorted list of project modules it imports``. Every
80 module seen is a key, so isolated modules still appear as nodes.
81 external
82 Top-level names imported that did not resolve inside the project
83 (``click``, ``json``, ...), with the number of importing modules.
84 unparsed
85 Project files that could not be parsed and so contributed nothing.
86 """
87
88 edges: dict[str, list[str]] = field(default_factory=dict)
89 external: Counter[str] = field(default_factory=Counter)
90 unparsed: list[Path] = field(default_factory=list)
91
92 @property
93 def edge_count(self) -> int:
94 """Total number of module-level edges."""
95 return sum(len(v) for v in self.edges.values())
96
97
98#: Directories that hold Python which is not part of the project's
99#: structure. ``docs`` carries the generated Sphinx ``_sphinx/conf.py``,
100#: which surfaced as a lone ``docs._sphinx.conf`` node on every project's
101#: module graph (found 2026-09-27 drafting plan B from this graph).
102_NON_SOURCE_DIR_NAMES: frozenset[str] = frozenset({"docs"})
103
104
105def _is_test_path(py_file: Path, root: Path) -> bool:
106 """True when the file sits under a test or non-source directory.
107
108 Only a top-level ``docs`` counts as non-source; a package that happens
109 to contain a ``docs`` subpackage (``oct.docs``) is source.
110 """
111 try:
112 parts = py_file.relative_to(root).parts[:-1]
113 except ValueError:
114 return False
115 if parts and parts[0] in _NON_SOURCE_DIR_NAMES:
116 return True
117 return any(part in _TEST_DIR_NAMES for part in parts)
118
119
120def _resolve_relative(module: str, is_package: bool, level: int, name: str | None) -> str | None:
121 """Resolve ``from ..x import y`` to a dotted name, or ``None`` if it escapes the root.
122
123 *module* is the importing module's dotted name; a package's
124 ``__init__`` imports relative to itself, a plain module relative to
125 its parent, which is why *is_package* matters.
126 """
127 base = module.split(".") if is_package else module.split(".")[:-1]
128 drop = level - 1
129 if drop > len(base):
130 return None
131 base = base[: len(base) - drop] if drop else base
132 if name:
133 base = base + name.split(".")
134 return ".".join(base) if base else None
135
136
137def _resolve_in_project(candidate: str, modules: set[str]) -> str | None:
138 """Return the longest project module that *candidate* names, or ``None``.
139
140 ``from oct.core import git`` may name the module ``oct.core.git`` or
141 the attribute ``git`` of the package ``oct.core``; the walk from the
142 longest prefix down picks whichever exists.
143 """
144 parts = candidate.split(".")
145 for end in range(len(parts), 0, -1):
146 prefix = ".".join(parts[:end])
147 if prefix in modules:
148 return prefix
149 return None
150
151
152def build_import_graph(root: Path) -> ImportGraph:
153 """Walk *root* and build the import-derived module graph.
154
155 Skips the standard exclusion directories and every test directory.
156 See the module docstring for the resolution rules.
157 """
158 func = "build_import_graph"
159 root = Path(root).resolve()
160 _dbg("OCT-DEPS", func, f"root={root}", 2)
161
162 files: dict[str, Path] = {}
163 for py_file in sorted(root.rglob("*.py")):
164 if _should_skip(py_file, root) or _is_test_path(py_file, root):
165 continue
166 files[module_name_for(py_file, root)] = py_file
167 modules = set(files)
168
169 graph = ImportGraph(edges={name: [] for name in files})
170 for name, py_file in files.items():
171 try:
172 tree = ast.parse(py_file.read_text(encoding="utf-8", errors="replace"))
173 except SyntaxError:
174 graph.unparsed.append(py_file)
175 _dbg("OCT-DEPS", func, f"unparsed: {py_file}", 3)
176 continue
177 is_package = py_file.name == "__init__.py"
178 targets: set[str] = set()
179 for node in ast.walk(tree):
180 candidates: list[str] = []
181 if isinstance(node, ast.Import):
182 candidates = [alias.name for alias in node.names]
183 elif isinstance(node, ast.ImportFrom):
184 if node.level:
185 base = _resolve_relative(name, is_package, node.level, node.module)
186 if base is None:
187 continue
188 candidates = [f"{base}.{alias.name}" for alias in node.names]
189 elif node.module:
190 candidates = [f"{node.module}.{alias.name}" for alias in node.names]
191 for candidate in candidates:
192 hit = _resolve_in_project(candidate, modules)
193 if hit is None:
194 graph.external[candidate.split(".")[0]] += 1
195 _dbg("OCT-DEPS", func, f"{name}: external {candidate}", 4)
196 elif hit != name:
197 targets.add(hit)
198 graph.edges[name] = sorted(targets)
199
200 _dbg("OCT-DEPS", func,
201 f"modules={len(files)} edges={graph.edge_count} "
202 f"external={len(graph.external)} unparsed={len(graph.unparsed)}", 2)
203 return graph
204
205
206def _package_of(module: str, depth: int) -> str:
207 """The package node *module* belongs to at *depth*.
208
209 The first *depth* dotted components; a name with fewer components is
210 its own node, so a package's ``__init__`` (``pkg.core``) and its
211 members (``pkg.core.git``) collapse to the same node. A dunder module
212 (``oct.__main__``) folds into its parent rather than standing alone.
213 """
214 parts = module.split(".")
215 if len(parts) > 1 and parts[-1].startswith("__"):
216 parts = parts[:-1]
217 return ".".join(parts[:depth])
218
219
220def collapse_to_packages(edges: dict[str, list[str]], depth: int = 2) -> dict[tuple[str, str], int]:
221 """Collapse module edges to package edges, weighted by count.
222
223 *depth* is how many dotted components make a package. For a project
224 laid out as ``<dist>/<pkg>/<subpkg>/module.py`` the natural overview
225 is depth 2 (``oct.core``, ``oct.git``); a flat project wants 1. Edges
226 inside one package are dropped - the overview is about the seams.
227 """
228 weights: Counter[tuple[str, str]] = Counter()
229 for src, targets in edges.items():
230 src_pkg = _package_of(src, depth)
231 for dst in targets:
232 dst_pkg = _package_of(dst, depth)
233 if src_pkg != dst_pkg:
234 weights[(src_pkg, dst_pkg)] += 1
235 return dict(weights)
236
237
238def _mermaid_id(name: str) -> str:
239 return name.replace(".", "_").replace("-", "_")
240
241
242def to_mermaid_flowchart(package_edges: dict[tuple[str, str], int]) -> str:
243 """Render package-level edges as a Mermaid ``flowchart LR``.
244
245 Edge labels carry the weight when it is more than one, so the chart
246 shows where the heavy coupling is without a second diagram.
247 """
248 lines = ["flowchart LR"]
249 nodes = sorted({n for edge in package_edges for n in edge})
250 for node in nodes:
251 lines.append(f' {_mermaid_id(node)}["{node}"]')
252 for (src, dst), weight in sorted(package_edges.items()):
253 label = f"|{weight}|" if weight > 1 else ""
254 lines.append(f" {_mermaid_id(src)} -->{label} {_mermaid_id(dst)}")
255 return "\n".join(lines)
256
257
258def to_dot(edges: dict[str, list[str]], title: str = "modules") -> str:
259 """Render module-level edges as Graphviz DOT, clustered by package.
260
261 Clusters make the module graph readable at oct's size: dot lays each
262 package out as a box, and the cross-package edges are the picture.
263 """
264 by_pkg: dict[str, list[str]] = {}
265 for module in edges:
266 by_pkg.setdefault(_package_of(module, 2), []).append(module)
267 lines = [
268 f'digraph "{title}" {{',
269 " rankdir=LR;",
270 ' node [shape=box, fontname="Helvetica", fontsize=10];',
271 ' edge [color="#666666", arrowsize=0.7];',
272 ]
273 for index, (pkg, members) in enumerate(sorted(by_pkg.items())):
274 lines.append(f" subgraph cluster_{index} {{")
275 lines.append(f' label="{pkg}"; style=rounded; color="#999999";')
276 for module in sorted(members):
277 short = module.rsplit(".", 1)[-1]
278 lines.append(f' "{module}" [label="{short}"];')
279 lines.append(" }")
280 for src, targets in sorted(edges.items()):
281 for dst in targets:
282 lines.append(f' "{src}" -> "{dst}";')
283 lines.append("}")
284 return "\n".join(lines)
285
286
287__all__ = [
288 "ImportGraph",
289 "build_import_graph",
290 "collapse_to_packages",
291 "to_dot",
292 "to_mermaid_flowchart",
293]
str _package_of(str module, int depth)
str to_mermaid_flowchart(dict[tuple[str, str], int] package_edges)
dict[tuple[str, str], int] collapse_to_packages(dict[str, list[str]] edges, int depth=2)
bool _is_test_path(Path py_file, Path root)
str|None _resolve_relative(str module, bool is_package, int level, str|None name)
str to_dot(dict[str, list[str]] edges, str title="modules")
str _mermaid_id(str name)
str|None _resolve_in_project(str candidate, set[str] modules)
ImportGraph build_import_graph(Path root)