Option C Tools
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.
15- Build a directed dependency graph (adjacency list).
16- Detect circular dependencies via depth-first search.
17- Output the graph in multiple formats: terminal summary, JSON, Mermaid,
18 and DOT.
19
20Diagnostics
21-----------
22Domain: OCT-DEPS
23Levels:
24 L2 — lifecycle
25 L3 — semantic details
26 L4 — deep tracing
27
28Contracts
29---------
30- Must not import project modules; analysis is purely static.
31- Must exit 1 if circular dependencies are detected.
32- Must handle missing or malformed Dependencies sections gracefully.
33"""
34
35import ast
36import json
37import re
38import sys
39from pathlib import Path
40
41from oct.core.exclusions import LINTER_EXCLUDE_DIRS, WILDCARD_EXCLUDE_DIRS
42
43
44# ---- Dependency extraction ----
45
46_DEP_SECTION_RE = re.compile(
47 r'Dependencies\s*\n\s*[-]+\s*\n(.*?)(?:\n\s*\n|\Z)',
48 re.DOTALL,
49)
50_DEP_LINE_RE = re.compile(r'^\s*[-*]\s+`?(\S+?)`?\s*(?:[-—:].*)?$', re.MULTILINE)
51
52
53def _extract_module_docstring(source: str) -> str | None:
54 """Extract the module-level docstring from Python source."""
55 try:
56 tree = ast.parse(source)
57 except SyntaxError:
58 return None
59 if (tree.body
60 and isinstance(tree.body[0], ast.Expr)
61 and isinstance(tree.body[0].value, ast.Constant)
62 and isinstance(tree.body[0].value.value, str)):
63 return tree.body[0].value.value
64 return None
65
66
67def extract_dependencies(source: str) -> list[str]:
68 """Parse the Dependencies section from a module docstring.
69
70 Returns a list of dependency module names.
71 """
72 docstring = _extract_module_docstring(source)
73 if not docstring:
74 return []
75 m = _DEP_SECTION_RE.search(docstring)
76 if not m:
77 return []
78 section = m.group(1)
79 return _DEP_LINE_RE.findall(section)
80
81
82# ---- Graph building ----
83
84def _should_skip(path: Path, root: Path) -> bool:
85 """Check if a path should be excluded."""
86 rel_parts = path.relative_to(root).parts
87 for part in rel_parts:
88 if part in LINTER_EXCLUDE_DIRS:
89 return True
90 for wc in WILDCARD_EXCLUDE_DIRS:
91 if part.startswith(wc.rstrip("*")):
92 return True
93 return False
94
95
96def module_name_for(py_file: Path, root: Path) -> str:
97 """Compute a dotted import-path key for *py_file* relative to *root*.
98
99 OI-521: using ``py_file.stem`` collided whenever two packages contain
100 modules of the same name (e.g. ``a/util.py`` and ``b/util.py`` both
101 reduced to ``util``). Returning the dotted import path makes each key
102 unique and aligns with the dotted names typically used in
103 ``Dependencies`` docstring sections.
104
105 ``__init__.py`` resolves to its parent package's dotted name; a file
106 directly under *root* keeps its stem as the fallback.
107 """
108 try:
109 rel = py_file.relative_to(root).with_suffix("")
110 except ValueError:
111 return py_file.stem
112 parts = list(rel.parts)
113 if parts and parts[-1] == "__init__":
114 parts = parts[:-1]
115 return ".".join(parts) if parts else py_file.stem
116
117
118def build_dependency_graph(root: Path) -> dict[str, list[str]]:
119 """Walk the project and build an adjacency list from Dependencies sections.
120
121 Keys are dotted import paths (OI-521), so modules sharing a stem
122 across packages no longer overwrite one another.
123 """
124 graph: dict[str, list[str]] = {}
125
126 for py_file in sorted(root.rglob("*.py")):
127 if _should_skip(py_file, root):
128 continue
129 try:
130 source = py_file.read_text(encoding="utf-8")
131 except (OSError, UnicodeDecodeError):
132 continue
133
134 module_name = module_name_for(py_file, root)
135 deps = extract_dependencies(source)
136 if deps:
137 graph[module_name] = deps
138 elif module_name not in graph:
139 graph[module_name] = []
140
141 return graph
142
143
144# ---- Cycle detection ----
145
146def detect_cycles(graph: dict[str, list[str]]) -> list[list[str]]:
147 """Detect circular dependencies using DFS. Returns list of cycle paths."""
148 cycles: list[list[str]] = []
149 visited: set[str] = set()
150 rec_stack: set[str] = set()
151
152 def _dfs(node: str, path: list[str]) -> None:
153 visited.add(node)
154 rec_stack.add(node)
155 path.append(node)
156
157 for dep in graph.get(node, []):
158 if dep in rec_stack:
159 cycle_start = path.index(dep)
160 cycles.append(path[cycle_start:] + [dep])
161 elif dep not in visited and dep in graph:
162 _dfs(dep, path)
163
164 path.pop()
165 rec_stack.discard(node)
166
167 for node in graph:
168 if node not in visited:
169 _dfs(node, [])
170
171 return cycles
172
173
174# ---- Output formatters ----
175
176def to_json(graph: dict[str, list[str]], cycles: list[list[str]]) -> str:
177 """Format dependency graph as JSON."""
178 return json.dumps({
179 "graph": graph,
180 "cycles": cycles,
181 "has_cycles": len(cycles) > 0,
182 }, indent=2)
183
184
185def to_mermaid(graph: dict[str, list[str]]) -> str:
186 """Format dependency graph as Mermaid diagram syntax."""
187 lines = ["graph TD"]
188 for module, deps in sorted(graph.items()):
189 for dep in deps:
190 lines.append(f" {module} --> {dep}")
191 return "\n".join(lines)
192
193
194def to_dot(graph: dict[str, list[str]]) -> str:
195 """Format dependency graph as DOT (Graphviz) syntax."""
196 lines = ["digraph dependencies {"]
197 lines.append(" rankdir=LR;")
198 for module, deps in sorted(graph.items()):
199 for dep in deps:
200 lines.append(f' "{module}" -> "{dep}";')
201 lines.append("}")
202 return "\n".join(lines)
203
204
205# ---- Main entry point ----
206
207def audit_pins(root: Path, json_output: bool = False) -> int:
208 """Audit pyproject.toml dependencies for unpinned version specifiers.
209
210 Reads ``[project.dependencies]`` and
211 ``[project.optional-dependencies]`` from ``pyproject.toml``. Reports
212 dependencies that use open ranges (``>=``, ``>``) with no exact or
213 upper-bound constraint alongside those that are strictly pinned.
214
215 Parameters
216 ----------
217 root
218 Project root containing ``pyproject.toml``.
219 json_output
220 If ``True``, prints a JSON summary. Otherwise prints a human-
221 readable table.
222
223 Returns
224 -------
225 Exit code: ``0`` if all dependencies have upper-bound or exact pins,
226 ``1`` if any open-ended ``>=``-only or ``>``-only specs are found,
227 ``2`` if ``pyproject.toml`` could not be read.
228 """
229 import json as _json
230 import re
231
232 toml_path = root / "pyproject.toml"
233 if not toml_path.exists():
234 msg = f"pyproject.toml not found at {toml_path}"
235 if json_output:
236 print(_json.dumps({"error": msg, "pinned": [], "open": [], "total": 0}))
237 else:
238 print(f"oct deps --audit-pins: {msg}", flush=True)
239 return 2
240
241 try:
242 try:
243 import tomllib # Python 3.11+
244 except ImportError:
245 try:
246 import tomli as tomllib # type: ignore[no-redef]
247 except ImportError:
248 # Fallback: minimal TOML parser for dependencies list only.
249 tomllib = None # type: ignore[assignment]
250
251 if tomllib is not None:
252 data = tomllib.loads(toml_path.read_text(encoding="utf-8"))
253 else:
254 # Minimal fallback: parse pyproject.toml manually for deps lines.
255 data = _parse_toml_deps_fallback(toml_path)
256
257 project = data.get("project", {})
258 all_deps: list[str] = list(project.get("dependencies", []))
259 opt_deps = project.get("optional-dependencies", {})
260 for extra_deps in opt_deps.values():
261 all_deps.extend(extra_deps)
262
263 except Exception as exc:
264 msg = f"Failed to read pyproject.toml: {exc}"
265 if json_output:
266 print(_json.dumps({"error": msg, "pinned": [], "open": [], "total": 0}))
267 else:
268 print(f"oct deps --audit-pins: {msg}", flush=True)
269 return 2
270
271 # Classify each dep specifier.
272 # "Open" = has >= or > with NO upper bound (no < or == in the specifier).
273 # "Pinned" = has ==, ~=, <=, <, or a [version] marker that constrains upward.
274 pinned: list[str] = []
275 open_: list[str] = []
276
277 for dep in all_deps:
278 spec = dep.strip()
279 if not spec:
280 continue
281 # Strip environment markers (e.g., "; python_version >= '3.10'")
282 spec_no_marker = spec.split(";")[0].strip()
283 # Check for exact pin or upper-bound constraint.
284 has_upper = bool(re.search(r"(==|~=|<=|<)", spec_no_marker))
285 has_lower_only = bool(re.search(r"[>]=?", spec_no_marker)) and not has_upper
286 if has_lower_only:
287 open_.append(spec)
288 else:
289 pinned.append(spec)
290
291 if json_output:
292 print(_json.dumps({
293 "pinned": pinned,
294 "open": open_,
295 "total": len(pinned) + len(open_),
296 }, indent=2))
297 else:
298 print(f"Dependencies audited: {len(pinned) + len(open_)} total")
299 if open_:
300 print(f"\n Open (no upper bound) — {len(open_)} found:")
301 for dep in open_:
302 print(f" {dep}")
303 if pinned:
304 print(f"\n Pinned — {len(pinned)} OK")
305 if not open_:
306 print("\nAll dependencies have upper-bound or exact pins. OK")
307
308 return 1 if open_ else 0
309
310
311def _parse_toml_deps_fallback(toml_path: Path) -> dict:
312 """Minimal fallback TOML parser — extracts dependency lines only."""
313 import re
314 data: dict = {"project": {"dependencies": [], "optional-dependencies": {}}}
315 text = toml_path.read_text(encoding="utf-8")
316 in_deps = False
317 for line in text.splitlines():
318 stripped = line.strip()
319 if stripped == "dependencies = [":
320 in_deps = True
321 continue
322 if in_deps:
323 if stripped == "]":
324 in_deps = False
325 continue
326 m = re.match(r'^["\'](.+)["\'],?$', stripped)
327 if m:
328 data["project"]["dependencies"].append(m.group(1))
329 return data
330
331
332def run_deps(root: Path, argv: list[str] | None = None) -> int:
333 """Run dependency analysis on the given project root.
334
335 Returns exit code: 0 if no cycles, 1 if circular dependencies found.
336 """
337 import argparse
338
339 parser = argparse.ArgumentParser(prog="oct deps", add_help=False)
340 parser.add_argument("--json", action="store_true", help="JSON output")
341 parser.add_argument("--mermaid", action="store_true", help="Mermaid diagram output")
342 parser.add_argument("--dot", action="store_true", help="DOT/Graphviz output")
343 parser.add_argument(
344 "--audit-pins",
345 action="store_true",
346 help=(
347 "Audit pyproject.toml dependencies for unpinned version specifiers. "
348 "Exit 1 if any open >= / >-only ranges are found."
349 ),
350 )
351 args = parser.parse_args(argv or [])
352
353 if args.audit_pins:
354 return audit_pins(root, json_output=args.json)
355
356 graph = build_dependency_graph(root)
357 cycles = detect_cycles(graph)
358
359 if args.json:
360 print(to_json(graph, cycles))
361 elif args.mermaid:
362 print(to_mermaid(graph))
363 elif args.dot:
364 print(to_dot(graph))
365 else:
366 # Terminal summary
367 total_modules = len(graph)
368 total_deps = sum(len(deps) for deps in graph.values())
369 print(f"Dependency Analysis: {total_modules} modules, {total_deps} dependencies")
370 if cycles:
371 print(f"\nCircular dependencies detected ({len(cycles)}):")
372 for cycle in cycles:
373 print(f" {' -> '.join(cycle)}")
374 else:
375 print("No circular dependencies found.")
376
377 # Show modules with dependencies
378 has_deps = {m: d for m, d in graph.items() if d}
379 if has_deps:
380 print(f"\nModules with declared dependencies ({len(has_deps)}):")
381 for module, deps in sorted(has_deps.items()):
382 print(f" {module}: {', '.join(deps)}")
383
384 return 1 if cycles else 0
str|None _extract_module_docstring(str source)
Definition oct_deps.py:53
int run_deps(Path root, list[str]|None argv=None)
Definition oct_deps.py:332
str to_json(dict[str, list[str]] graph, list[list[str]] cycles)
Definition oct_deps.py:176
bool _should_skip(Path path, Path root)
Definition oct_deps.py:84
list[str] extract_dependencies(str source)
Definition oct_deps.py:67
str to_dot(dict[str, list[str]] graph)
Definition oct_deps.py:194
dict[str, list[str]] build_dependency_graph(Path root)
Definition oct_deps.py:118
int audit_pins(Path root, bool json_output=False)
Definition oct_deps.py:207
str module_name_for(Path py_file, Path root)
Definition oct_deps.py:96
dict _parse_toml_deps_fallback(Path toml_path)
Definition oct_deps.py:311
list[list[str]] detect_cycles(dict[str, list[str]] graph)
Definition oct_deps.py:146
str to_mermaid(dict[str, list[str]] graph)
Definition oct_deps.py:185