74 """The result of :func:`build_import_graph`.
79 ``module -> sorted list of project modules it imports``. Every
80 module seen is a key, so isolated modules still appear as nodes.
82 Top-level names imported that did not resolve inside the project
83 (``click``, ``json``, ...), with the number of importing modules.
85 Project files that could not be parsed and so contributed nothing.
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)
94 """Total number of module-level edges."""
95 return sum(len(v)
for v
in self.
edges.values())
102_NON_SOURCE_DIR_NAMES: frozenset[str] = frozenset({
"docs"})
153 """Walk *root* and build the import-derived module graph.
155 Skips the standard exclusion directories and every test directory.
156 See the module docstring for the resolution rules.
158 func =
"build_import_graph"
159 root = Path(root).resolve()
160 _dbg(
"OCT-DEPS", func, f
"root={root}", 2)
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):
166 files[module_name_for(py_file, root)] = py_file
169 graph =
ImportGraph(edges={name: []
for name
in files})
170 for name, py_file
in files.items():
172 tree = ast.parse(py_file.read_text(encoding=
"utf-8", errors=
"replace"))
174 graph.unparsed.append(py_file)
175 _dbg(
"OCT-DEPS", func, f
"unparsed: {py_file}", 3)
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):
188 candidates = [f
"{base}.{alias.name}" for alias
in node.names]
190 candidates = [f
"{node.module}.{alias.name}" for alias
in node.names]
191 for candidate
in candidates:
194 graph.external[candidate.split(
".")[0]] += 1
195 _dbg(
"OCT-DEPS", func, f
"{name}: external {candidate}", 4)
198 graph.edges[name] = sorted(targets)
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)
221 """Collapse module edges to package edges, weighted by count.
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.
228 weights: Counter[tuple[str, str]] = Counter()
229 for src, targets
in edges.items():
233 if src_pkg != dst_pkg:
234 weights[(src_pkg, dst_pkg)] += 1
258def to_dot(edges: dict[str, list[str]], title: str =
"modules") -> str:
259 """Render module-level edges as Graphviz DOT, clustered by package.
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.
264 by_pkg: dict[str, list[str]] = {}
266 by_pkg.setdefault(
_package_of(module, 2), []).append(module)
268 f
'digraph "{title}" {{',
270 ' node [shape=box, fontname="Helvetica", fontsize=10];',
271 ' edge [color="#666666", arrowsize=0.7];',
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}"];')
280 for src, targets
in sorted(edges.items()):
282 lines.append(f
' "{src}" -> "{dst}";')
284 return "\n".join(lines)