8Analyse inter-module dependencies within an Option C project by parsing
9the ``Dependencies`` docstring section from all Python files.
13- Walk the project tree and extract ``Dependencies`` sections from module
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,
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.
39from pathlib
import Path
41from oct.core.exclusions
import LINTER_EXCLUDE_DIRS, WILDCARD_EXCLUDE_DIRS
46_DEP_SECTION_RE = re.compile(
47 r'Dependencies\s*\n\s*[-]+\s*\n(.*?)(?:\n\s*\n|\Z)',
50_DEP_LINE_RE = re.compile(
r'^\s*[-*]\s+`?(\S+?)`?\s*(?:[-—:].*)?$', re.MULTILINE)
54 """Extract the module-level docstring from Python source."""
56 tree = ast.parse(source)
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
68 """Parse the Dependencies section from a module docstring.
70 Returns a list of dependency module names.
75 m = _DEP_SECTION_RE.search(docstring)
79 return _DEP_LINE_RE.findall(section)
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:
90 for wc
in WILDCARD_EXCLUDE_DIRS:
91 if part.startswith(wc.rstrip(
"*")):
97 """Compute a dotted import-path key for *py_file* relative to *root*.
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.
105 ``__init__.py`` resolves to its parent package's dotted name; a file
106 directly under *root* keeps its stem as the fallback.
109 rel = py_file.relative_to(root).with_suffix(
"")
112 parts = list(rel.parts)
113 if parts
and parts[-1] ==
"__init__":
115 return ".".join(parts)
if parts
else py_file.stem
119 """Walk the project and build an adjacency list from Dependencies sections.
121 Keys are dotted import paths (OI-521), so modules sharing a stem
122 across packages no longer overwrite one another.
124 graph: dict[str, list[str]] = {}
126 for py_file
in sorted(root.rglob(
"*.py")):
130 source = py_file.read_text(encoding=
"utf-8")
131 except (OSError, UnicodeDecodeError):
137 graph[module_name] = deps
138 elif module_name
not in graph:
139 graph[module_name] = []
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()
152 def _dfs(node: str, path: list[str]) ->
None:
157 for dep
in graph.get(node, []):
159 cycle_start = path.index(dep)
160 cycles.append(path[cycle_start:] + [dep])
161 elif dep
not in visited
and dep
in graph:
165 rec_stack.discard(node)
168 if node
not in visited:
176def to_json(graph: dict[str, list[str]], cycles: list[list[str]]) -> str:
177 """Format dependency graph as JSON."""
181 "has_cycles": len(cycles) > 0,
186 """Format dependency graph as Mermaid diagram syntax."""
188 for module, deps
in sorted(graph.items()):
190 lines.append(f
" {module} --> {dep}")
191 return "\n".join(lines)
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()):
200 lines.append(f
' "{module}" -> "{dep}";')
202 return "\n".join(lines)
208 """Audit pyproject.toml dependencies for unpinned version specifiers.
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.
218 Project root containing ``pyproject.toml``.
220 If ``True``, prints a JSON summary. Otherwise prints a human-
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.
232 toml_path = root /
"pyproject.toml"
233 if not toml_path.exists():
234 msg = f
"pyproject.toml not found at {toml_path}"
236 print(_json.dumps({
"error": msg,
"pinned": [],
"open": [],
"total": 0}))
238 print(f
"oct deps --audit-pins: {msg}", flush=
True)
246 import tomli
as tomllib
251 if tomllib
is not None:
252 data = tomllib.loads(toml_path.read_text(encoding=
"utf-8"))
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)
263 except Exception
as exc:
264 msg = f
"Failed to read pyproject.toml: {exc}"
266 print(_json.dumps({
"error": msg,
"pinned": [],
"open": [],
"total": 0}))
268 print(f
"oct deps --audit-pins: {msg}", flush=
True)
274 pinned: list[str] = []
275 open_: list[str] = []
282 spec_no_marker = spec.split(
";")[0].strip()
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
295 "total": len(pinned) + len(open_),
298 print(f
"Dependencies audited: {len(pinned) + len(open_)} total")
300 print(f
"\n Open (no upper bound) — {len(open_)} found:")
304 print(f
"\n Pinned — {len(pinned)} OK")
306 print(
"\nAll dependencies have upper-bound or exact pins. OK")
308 return 1
if open_
else 0
312 """Minimal fallback TOML parser — extracts dependency lines only."""
314 data: dict = {
"project": {
"dependencies": [],
"optional-dependencies": {}}}
315 text = toml_path.read_text(encoding=
"utf-8")
317 for line
in text.splitlines():
318 stripped = line.strip()
319 if stripped ==
"dependencies = [":
326 m = re.match(
r'^["\'](.+)["\'],?$', stripped)
328 data[
"project"][
"dependencies"].append(m.group(1))
332def run_deps(root: Path, argv: list[str] |
None =
None) -> int:
333 """Run dependency analysis on the given project root.
335 Returns exit code: 0 if no cycles, 1 if circular dependencies found.
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")
347 "Audit pyproject.toml dependencies for unpinned version specifiers. "
348 "Exit 1 if any open >= / >-only ranges are found."
351 args = parser.parse_args(argv
or [])
354 return audit_pins(root, json_output=args.json)
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")
371 print(f
"\nCircular dependencies detected ({len(cycles)}):")
373 print(f
" {' -> '.join(cycle)}")
375 print(
"No circular dependencies found.")
378 has_deps = {m: d
for m, d
in graph.items()
if d}
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)}")
384 return 1
if cycles
else 0
str|None _extract_module_docstring(str source)
int run_deps(Path root, list[str]|None argv=None)
str to_json(dict[str, list[str]] graph, list[list[str]] cycles)
bool _should_skip(Path path, Path root)
list[str] extract_dependencies(str source)
str to_dot(dict[str, list[str]] graph)
dict[str, list[str]] build_dependency_graph(Path root)
int audit_pins(Path root, bool json_output=False)
str module_name_for(Path py_file, Path root)
dict _parse_toml_deps_fallback(Path toml_path)
list[list[str]] detect_cycles(dict[str, list[str]] graph)
str to_mermaid(dict[str, list[str]] graph)