oct
Loading...
Searching...
No Matches
oct_deps.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/deps/oct_deps.py
4
5"""
6Purpose
7-------
8Analyse inter-module dependencies within an Option C project by parsing
9the ``Dependencies`` docstring section from all Python files.
10
11Responsibilities
12----------------
13- Walk the project tree and extract ``Dependencies`` sections from module
14 docstrings. The section parsing (:func:`dependency_items`,
15 :func:`entry_marker`) is shared with the ``dependencies_match`` lint
16 rule in :mod:`oct.deps.dependency_check`, so both read an entry alike.
17- Build a directed dependency graph (adjacency list).
18- Detect circular dependencies via depth-first search, over the EAGER
19 entries only: an entry whose purpose starts with ``lazy`` or ``not
20 imported`` (operator ruling 2026-09-28) is drawn but never part of a
21 cycle, because lazy imports are how the code breaks import cycles.
22- Output the graph in multiple formats: terminal summary, JSON, Mermaid,
23 and DOT.
24
25Diagnostics
26-----------
27Domain: OCT-DEPS
28Levels:
29 L2 - lifecycle
30 L3 - semantic details
31 L4 - deep tracing
32
33Contracts
34---------
35- Must not import project modules; analysis is purely static.
36- Must exit 1 if circular dependencies are detected.
37- Must handle missing or malformed Dependencies sections gracefully.
38
39Dependencies
40------------
41- oct.core.exclusions (directory exclusion lists)
42- oct.core.diagnostics (_dbg structured logger)
43- tomli (lazy; TOML parsing for audit_pins on Python before 3.11)
44"""
45
46import ast
47import json
48import re
49import sys
50from dataclasses import dataclass
51from pathlib import Path
52
53from oct.core.exclusions import LINTER_EXCLUDE_DIRS, WILDCARD_EXCLUDE_DIRS
54from oct.core.diagnostics import _dbg
55
56
57# ---- Dependency extraction ----
58
59_DEP_SECTION_RE = re.compile(
60 r'Dependencies\s*\n\s*[-]+\s*\n(.*?)(?:\n\s*\n|\Z)',
61 re.DOTALL,
62)
63_BULLET_RE = re.compile(r'^[ \t]*[-*][ \t]+(.*)$')
64# The description may follow the module name after "-", ":" or "(". The
65# repo-wide convention is "- module (what it provides)"; until 2026-09-27
66# the "(" form was not accepted, so this matched zero lines in 331 oct
67# modules, oct deps reported "0 dependencies" for every project, and no
68# Sphinx dependencies page was ever generated. A bare name with no
69# description still matches. Applied to one physical line at a time: the
70# earlier multi-line form let a bare-name bullet swallow the next entry.
71_DEP_NAME_RE = re.compile(r'^`?(\S+?)`?[ \t]*(?:[-—:(].*)?$')
72# A marker is a whole word at the start of the purpose: "lazy;" counts,
73# "lazy-evaluation helpers" does not.
74# OCT-LINT: disable=no_hardcoded_secrets reason="Regex for the lazy and not-imported entry markers; no credential material (operator-ruled 2026-09-28)"
75_MARKER_RE = re.compile(r'^(lazy|not imported)(?![\w-])')
76
77
78@dataclass(frozen=True)
80 """One item of a Dependencies section.
81
82 *first_line* is the item's first physical line with the bullet marker
83 stripped; *text* is that line with its continuation lines joined by
84 single spaces, so a wrapped purpose reads as one.
85 """
86
87 bulleted: bool
88 first_line: str
89 text: str
90
91
92def dependency_items(docstring: str | None) -> list[SectionItem] | None:
93 """The items of *docstring*'s Dependencies section, or ``None`` if it has none.
94
95 A non-bullet line after a bullet continues it; a non-bullet line before
96 any bullet is an item of its own (``bulleted`` false).
97 """
98 m = _DEP_SECTION_RE.search(docstring or "")
99 if not m:
100 return None
101 items: list[list] = []
102 for line in m.group(1).splitlines():
103 b = _BULLET_RE.match(line)
104 if b:
105 items.append([True, b.group(1).strip(), b.group(1).strip()])
106 elif line.strip() and items:
107 items[-1][2] += " " + line.strip()
108 elif line.strip():
109 items.append([False, line.strip(), line.strip()])
110 return [SectionItem(*item) for item in items]
111
112
113def entry_marker(purpose: str) -> str | None:
114 """``"lazy"``, ``"not imported"`` or ``None`` for an entry's purpose text."""
115 m = _MARKER_RE.match(purpose.strip().lstrip("`*_").lower())
116 return m.group(1) if m else None
117
118
119def dependency_entries(docstring: str | None) -> list[tuple[str, bool]]:
120 """``(name, eager)`` for every bulleted entry of *docstring*'s Dependencies section.
121
122 The name comes from the entry's first line; the marker from the joined
123 text, so ``oct deps`` and the ``dependencies_match`` rule read a
124 wrapped entry the same way. An entry marked ``lazy`` or ``not
125 imported`` is not eager.
126 """
127 out: list[tuple[str, bool]] = []
128 for item in dependency_items(docstring) or []:
129 if not item.bulleted:
130 continue
131 m = _DEP_NAME_RE.match(item.first_line)
132 if not m:
133 continue
134 purpose = item.text[m.end(1):].lstrip(" \t`-—:(")
135 out.append((m.group(1), entry_marker(purpose) is None))
136 return out
137
138
139def _extract_module_docstring(source: str) -> str | None:
140 """Extract the module-level docstring from Python source."""
141 try:
142 tree = ast.parse(source)
143 except SyntaxError:
144 return None
145 if (tree.body
146 and isinstance(tree.body[0], ast.Expr)
147 and isinstance(tree.body[0].value, ast.Constant)
148 and isinstance(tree.body[0].value.value, str)):
149 return tree.body[0].value.value
150 return None
151
152
153def extract_dependencies(source: str, *, eager_only: bool = False) -> list[str]:
154 """Parse the Dependencies section from a module docstring.
155
156 Returns a list of dependency module names. With *eager_only*, entries
157 marked ``lazy`` or ``not imported`` are left out (cycle detection).
158 """
159 return [name for name, eager in dependency_entries(_extract_module_docstring(source))
160 if eager or not eager_only]
161
162
163# ---- Graph building ----
164
165def _should_skip(path: Path, root: Path) -> bool:
166 """Check if a path should be excluded.
167
168 The exclusion lists name DIRECTORIES, and are matched against the
169 directory components only, as the linter's ``is_excluded_dir`` does.
170 Until 2026-09-27 the file name was matched too, so the ``cache``,
171 ``logs`` and ``venv`` wildcards silently dropped any module whose name
172 merely started with one of them: ``oct.testing.cache``,
173 ``oc_chat.core.cache_usage`` and three others were missing from
174 ``oct deps``, every dependency overview page and every call graph.
175 """
176 rel_parts = path.relative_to(root).parts[:-1]
177 for part in rel_parts:
178 if part in LINTER_EXCLUDE_DIRS:
179 return True
180 for wc in WILDCARD_EXCLUDE_DIRS:
181 if part.startswith(wc.rstrip("*")):
182 return True
183 return False
184
185
186def module_name_for(py_file: Path, root: Path) -> str:
187 """Compute a dotted import-path key for *py_file* relative to *root*.
188
189 OI-521: using ``py_file.stem`` collided whenever two packages contain
190 modules of the same name (e.g. ``a/util.py`` and ``b/util.py`` both
191 reduced to ``util``). Returning the dotted import path makes each key
192 unique and aligns with the dotted names typically used in
193 ``Dependencies`` docstring sections.
194
195 ``__init__.py`` resolves to its parent package's dotted name; a file
196 directly under *root* keeps its stem as the fallback.
197 """
198 try:
199 rel = py_file.relative_to(root).with_suffix("")
200 except ValueError:
201 return py_file.stem
202 parts = list(rel.parts)
203 if parts and parts[-1] == "__init__":
204 parts = parts[:-1]
205 return ".".join(parts) if parts else py_file.stem
206
207
208def build_dependency_graphs(root: Path) -> tuple[dict[str, list[str]], dict[str, list[str]]]:
209 """Walk the project once; return ``(every entry, eager entries only)`` graphs.
210
211 Keys are dotted import paths (OI-521), so modules sharing a stem
212 across packages no longer overwrite one another.
213 """
214 func = "build_dependency_graphs"
215 _dbg("OCT-DEPS", func, "building the dependency graph", 3)
216 graph: dict[str, list[str]] = {}
217 eager: dict[str, list[str]] = {}
218
219 for py_file in sorted(root.rglob("*.py")):
220 if _should_skip(py_file, root):
221 continue
222 try:
223 source = py_file.read_text(encoding="utf-8")
224 except (OSError, UnicodeDecodeError):
225 continue
226
227 module_name = module_name_for(py_file, root)
229 if entries or module_name not in graph:
230 graph[module_name] = [name for name, _ in entries]
231 eager[module_name] = [name for name, is_eager in entries if is_eager]
232
233 return graph, eager
234
235
236def build_dependency_graph(root: Path, *, eager_only: bool = False) -> dict[str, list[str]]:
237 """One graph of :func:`build_dependency_graphs`: every entry, or the eager ones."""
238 graph, eager = build_dependency_graphs(root)
239 return eager if eager_only else graph
240
241
242# ---- Cycle detection ----
243
244def detect_cycles(graph: dict[str, list[str]]) -> list[list[str]]:
245 """Detect circular dependencies using DFS. Returns list of cycle paths."""
246 func = "detect_cycles"
247 _dbg("OCT-DEPS", func, "scanning the graph for cycles", 3)
248 cycles: list[list[str]] = []
249 visited: set[str] = set()
250 rec_stack: set[str] = set()
251
252 def _dfs(node: str, path: list[str]) -> None:
253 visited.add(node)
254 rec_stack.add(node)
255 path.append(node)
256
257 for dep in graph.get(node, []):
258 if dep in rec_stack:
259 cycle_start = path.index(dep)
260 cycles.append(path[cycle_start:] + [dep])
261 elif dep not in visited and dep in graph:
262 _dfs(dep, path)
263
264 path.pop()
265 rec_stack.discard(node)
266
267 for node in graph:
268 if node not in visited:
269 _dfs(node, [])
270
271 return cycles
272
273
274# ---- Output formatters ----
275
276def to_json(graph: dict[str, list[str]], cycles: list[list[str]]) -> str:
277 """Format dependency graph as JSON."""
278 return json.dumps({
279 "graph": graph,
280 "cycles": cycles,
281 "has_cycles": len(cycles) > 0,
282 }, indent=2)
283
284
285def to_mermaid(graph: dict[str, list[str]]) -> str:
286 """Format dependency graph as Mermaid diagram syntax."""
287 lines = ["graph TD"]
288 for module, deps in sorted(graph.items()):
289 for dep in deps:
290 lines.append(f" {module} --> {dep}")
291 return "\n".join(lines)
292
293
294def to_dot(graph: dict[str, list[str]]) -> str:
295 """Format dependency graph as DOT (Graphviz) syntax."""
296 lines = ["digraph dependencies {"]
297 lines.append(" rankdir=LR;")
298 for module, deps in sorted(graph.items()):
299 for dep in deps:
300 lines.append(f' "{module}" -> "{dep}";')
301 lines.append("}")
302 return "\n".join(lines)
303
304
305# ---- Main entry point ----
306
307def audit_pins(root: Path, json_output: bool = False) -> int:
308 """Audit pyproject.toml dependencies for unpinned version specifiers.
309
310 Reads ``[project.dependencies]`` and
311 ``[project.optional-dependencies]`` from ``pyproject.toml``. Reports
312 dependencies that use open ranges (``>=``, ``>``) with no exact or
313 upper-bound constraint alongside those that are strictly pinned.
314
315 Parameters
316 ----------
317 root
318 Project root containing ``pyproject.toml``.
319 json_output
320 If ``True``, prints a JSON summary. Otherwise prints a human-
321 readable table.
322
323 Returns
324 -------
325 Exit code: ``0`` if all dependencies have upper-bound or exact pins,
326 ``1`` if any open-ended ``>=``-only or ``>``-only specs are found,
327 ``2`` if ``pyproject.toml`` could not be read.
328 """
329 import json as _json
330 import re
331
332 toml_path = root / "pyproject.toml"
333 if not toml_path.exists():
334 msg = f"pyproject.toml not found at {toml_path}"
335 if json_output:
336 print(_json.dumps({"error": msg, "pinned": [], "open": [], "total": 0}))
337 else:
338 print(f"oct deps --audit-pins: {msg}", flush=True)
339 return 2
340
341 try:
342 try:
343 import tomllib # Python 3.11+
344 except ImportError:
345 try:
346 import tomli as tomllib # type: ignore[no-redef]
347 except ImportError:
348 # Fallback: minimal TOML parser for dependencies list only.
349 tomllib = None # type: ignore[assignment]
350
351 if tomllib is not None:
352 data = tomllib.loads(toml_path.read_text(encoding="utf-8"))
353 else:
354 # Minimal fallback: parse pyproject.toml manually for deps lines.
355 data = _parse_toml_deps_fallback(toml_path)
356
357 project = data.get("project", {})
358 all_deps: list[str] = list(project.get("dependencies", []))
359 opt_deps = project.get("optional-dependencies", {})
360 for extra_deps in opt_deps.values():
361 all_deps.extend(extra_deps)
362
363 except Exception as exc:
364 msg = f"Failed to read pyproject.toml: {exc}"
365 if json_output:
366 print(_json.dumps({"error": msg, "pinned": [], "open": [], "total": 0}))
367 else:
368 print(f"oct deps --audit-pins: {msg}", flush=True)
369 return 2
370
371 # Classify each dep specifier.
372 # "Open" = has >= or > with NO upper bound (no < or == in the specifier).
373 # "Pinned" = has ==, ~=, <=, <, or a [version] marker that constrains upward.
374 pinned: list[str] = []
375 open_: list[str] = []
376
377 for dep in all_deps:
378 spec = dep.strip()
379 if not spec:
380 continue
381 # Strip environment markers (e.g., "; python_version >= '3.10'")
382 spec_no_marker = spec.split(";")[0].strip()
383 # Check for exact pin or upper-bound constraint.
384 has_upper = bool(re.search(r"(==|~=|<=|<)", spec_no_marker))
385 has_lower_only = bool(re.search(r"[>]=?", spec_no_marker)) and not has_upper
386 if has_lower_only:
387 open_.append(spec)
388 else:
389 pinned.append(spec)
390
391 if json_output:
392 print(_json.dumps({
393 "pinned": pinned,
394 "open": open_,
395 "total": len(pinned) + len(open_),
396 }, indent=2))
397 else:
398 print(f"Dependencies audited: {len(pinned) + len(open_)} total")
399 if open_:
400 print(f"\n Open (no upper bound) - {len(open_)} found:")
401 for dep in open_:
402 print(f" {dep}")
403 if pinned:
404 print(f"\n Pinned - {len(pinned)} OK")
405 if not open_:
406 print("\nAll dependencies have upper-bound or exact pins. OK")
407
408 return 1 if open_ else 0
409
410
411def _parse_toml_deps_fallback(toml_path: Path) -> dict:
412 """Minimal fallback TOML parser - extracts dependency lines only."""
413 import re
414 data: dict = {"project": {"dependencies": [], "optional-dependencies": {}}}
415 text = toml_path.read_text(encoding="utf-8")
416 in_deps = False
417 for line in text.splitlines():
418 stripped = line.strip()
419 if stripped == "dependencies = [":
420 in_deps = True
421 continue
422 if in_deps:
423 if stripped == "]":
424 in_deps = False
425 continue
426 m = re.match(r'^["\'](.+)["\'],?$', stripped)
427 if m:
428 data["project"]["dependencies"].append(m.group(1))
429 return data
430
431
432def run_deps(root: Path, argv: list[str] | None = None) -> int:
433 """Run dependency analysis on the given project root.
434
435 Returns exit code: 0 if no cycles, 1 if circular dependencies found.
436 """
437 import argparse
438
439 parser = argparse.ArgumentParser(prog="oct deps", add_help=False)
440 parser.add_argument("--json", action="store_true", help="JSON output")
441 parser.add_argument("--mermaid", action="store_true", help="Mermaid diagram output")
442 parser.add_argument("--dot", action="store_true", help="DOT/Graphviz output")
443 parser.add_argument(
444 "--audit-pins",
445 action="store_true",
446 help=(
447 "Audit pyproject.toml dependencies for unpinned version specifiers. "
448 "Exit 1 if any open >= / >-only ranges are found."
449 ),
450 )
451 args = parser.parse_args(argv or [])
452
453 if args.audit_pins:
454 return audit_pins(root, json_output=args.json)
455
456 graph, eager = build_dependency_graphs(root)
457 # Cycles over eager entries only: a lazy import is a deliberate break.
458 cycles = detect_cycles(eager)
459
460 if args.json:
461 print(to_json(graph, cycles))
462 elif args.mermaid:
463 print(to_mermaid(graph))
464 elif args.dot:
465 print(to_dot(graph))
466 else:
467 # Terminal summary
468 total_modules = len(graph)
469 total_deps = sum(len(deps) for deps in graph.values())
470 print(f"Dependency Analysis: {total_modules} modules, {total_deps} dependencies")
471 if cycles:
472 print(f"\nCircular dependencies detected ({len(cycles)}):")
473 for cycle in cycles:
474 print(f" {' -> '.join(cycle)}")
475 else:
476 print("No circular dependencies found.")
477
478 # Show modules with dependencies
479 has_deps = {m: d for m, d in graph.items() if d}
480 if has_deps:
481 print(f"\nModules with declared dependencies ({len(has_deps)}):")
482 for module, deps in sorted(has_deps.items()):
483 print(f" {module}: {', '.join(deps)}")
484
485 return 1 if cycles else 0
str|None _extract_module_docstring(str source)
Definition oct_deps.py:139
str|None entry_marker(str purpose)
Definition oct_deps.py:113
list[SectionItem]|None dependency_items(str|None docstring)
Definition oct_deps.py:92
int run_deps(Path root, list[str]|None argv=None)
Definition oct_deps.py:432
str to_json(dict[str, list[str]] graph, list[list[str]] cycles)
Definition oct_deps.py:276
bool _should_skip(Path path, Path root)
Definition oct_deps.py:165
dict[str, list[str]] build_dependency_graph(Path root, *, bool eager_only=False)
Definition oct_deps.py:236
list[str] extract_dependencies(str source, *, bool eager_only=False)
Definition oct_deps.py:153
str to_dot(dict[str, list[str]] graph)
Definition oct_deps.py:294
int audit_pins(Path root, bool json_output=False)
Definition oct_deps.py:307
str module_name_for(Path py_file, Path root)
Definition oct_deps.py:186
dict _parse_toml_deps_fallback(Path toml_path)
Definition oct_deps.py:411
list[list[str]] detect_cycles(dict[str, list[str]] graph)
Definition oct_deps.py:244
list[tuple[str, bool]] dependency_entries(str|None docstring)
Definition oct_deps.py:119
str to_mermaid(dict[str, list[str]] graph)
Definition oct_deps.py:285
tuple[dict[str, list[str]], dict[str, list[str]]] build_dependency_graphs(Path root)
Definition oct_deps.py:208