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
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,
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.
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)
50from dataclasses
import dataclass
51from pathlib
import Path
53from oct.core.exclusions
import LINTER_EXCLUDE_DIRS, WILDCARD_EXCLUDE_DIRS
54from oct.core.diagnostics
import _dbg
59_DEP_SECTION_RE = re.compile(
60 r'Dependencies\s*\n\s*[-]+\s*\n(.*?)(?:\n\s*\n|\Z)',
63_BULLET_RE = re.compile(
r'^[ \t]*[-*][ \t]+(.*)$')
71_DEP_NAME_RE = re.compile(
r'^`?(\S+?)`?[ \t]*(?:[-—:(].*)?$')
75_MARKER_RE = re.compile(
r'^(lazy|not imported)(?![\w-])')
78@dataclass(frozen=True)
80 """One item of a Dependencies section.
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.
93 """The items of *docstring*'s Dependencies section, or ``None`` if it has none.
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).
98 m = _DEP_SECTION_RE.search(docstring
or "")
101 items: list[list] = []
102 for line
in m.group(1).splitlines():
103 b = _BULLET_RE.match(line)
105 items.append([
True, b.group(1).strip(), b.group(1).strip()])
106 elif line.strip()
and items:
107 items[-1][2] +=
" " + line.strip()
109 items.append([
False, line.strip(), line.strip()])
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
120 """``(name, eager)`` for every bulleted entry of *docstring*'s Dependencies section.
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.
127 out: list[tuple[str, bool]] = []
129 if not item.bulleted:
131 m = _DEP_NAME_RE.match(item.first_line)
134 purpose = item.text[m.end(1):].lstrip(
" \t`-—:(")
140 """Extract the module-level docstring from Python source."""
142 tree = ast.parse(source)
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
154 """Parse the Dependencies section from a module docstring.
156 Returns a list of dependency module names. With *eager_only*, entries
157 marked ``lazy`` or ``not imported`` are left out (cycle detection).
160 if eager
or not eager_only]
166 """Check if a path should be excluded.
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.
176 rel_parts = path.relative_to(root).parts[:-1]
177 for part
in rel_parts:
178 if part
in LINTER_EXCLUDE_DIRS:
180 for wc
in WILDCARD_EXCLUDE_DIRS:
181 if part.startswith(wc.rstrip(
"*")):
187 """Compute a dotted import-path key for *py_file* relative to *root*.
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.
195 ``__init__.py`` resolves to its parent package's dotted name; a file
196 directly under *root* keeps its stem as the fallback.
199 rel = py_file.relative_to(root).with_suffix(
"")
202 parts = list(rel.parts)
203 if parts
and parts[-1] ==
"__init__":
205 return ".".join(parts)
if parts
else py_file.stem
209 """Walk the project once; return ``(every entry, eager entries only)`` graphs.
211 Keys are dotted import paths (OI-521), so modules sharing a stem
212 across packages no longer overwrite one another.
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]] = {}
219 for py_file
in sorted(root.rglob(
"*.py")):
223 source = py_file.read_text(encoding=
"utf-8")
224 except (OSError, UnicodeDecodeError):
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]
237 """One graph of :func:`build_dependency_graphs`: every entry, or the eager ones."""
239 return eager
if eager_only
else graph
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()
252 def _dfs(node: str, path: list[str]) ->
None:
257 for dep
in graph.get(node, []):
259 cycle_start = path.index(dep)
260 cycles.append(path[cycle_start:] + [dep])
261 elif dep
not in visited
and dep
in graph:
265 rec_stack.discard(node)
268 if node
not in visited:
276def to_json(graph: dict[str, list[str]], cycles: list[list[str]]) -> str:
277 """Format dependency graph as JSON."""
281 "has_cycles": len(cycles) > 0,
286 """Format dependency graph as Mermaid diagram syntax."""
288 for module, deps
in sorted(graph.items()):
290 lines.append(f
" {module} --> {dep}")
291 return "\n".join(lines)
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()):
300 lines.append(f
' "{module}" -> "{dep}";')
302 return "\n".join(lines)
308 """Audit pyproject.toml dependencies for unpinned version specifiers.
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.
318 Project root containing ``pyproject.toml``.
320 If ``True``, prints a JSON summary. Otherwise prints a human-
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.
332 toml_path = root /
"pyproject.toml"
333 if not toml_path.exists():
334 msg = f
"pyproject.toml not found at {toml_path}"
336 print(_json.dumps({
"error": msg,
"pinned": [],
"open": [],
"total": 0}))
338 print(f
"oct deps --audit-pins: {msg}", flush=
True)
346 import tomli
as tomllib
351 if tomllib
is not None:
352 data = tomllib.loads(toml_path.read_text(encoding=
"utf-8"))
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)
363 except Exception
as exc:
364 msg = f
"Failed to read pyproject.toml: {exc}"
366 print(_json.dumps({
"error": msg,
"pinned": [],
"open": [],
"total": 0}))
368 print(f
"oct deps --audit-pins: {msg}", flush=
True)
374 pinned: list[str] = []
375 open_: list[str] = []
382 spec_no_marker = spec.split(
";")[0].strip()
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
395 "total": len(pinned) + len(open_),
398 print(f
"Dependencies audited: {len(pinned) + len(open_)} total")
400 print(f
"\n Open (no upper bound) - {len(open_)} found:")
404 print(f
"\n Pinned - {len(pinned)} OK")
406 print(
"\nAll dependencies have upper-bound or exact pins. OK")
408 return 1
if open_
else 0
412 """Minimal fallback TOML parser - extracts dependency lines only."""
414 data: dict = {
"project": {
"dependencies": [],
"optional-dependencies": {}}}
415 text = toml_path.read_text(encoding=
"utf-8")
417 for line
in text.splitlines():
418 stripped = line.strip()
419 if stripped ==
"dependencies = [":
426 m = re.match(
r'^["\'](.+)["\'],?$', stripped)
428 data[
"project"][
"dependencies"].append(m.group(1))
432def run_deps(root: Path, argv: list[str] |
None =
None) -> int:
433 """Run dependency analysis on the given project root.
435 Returns exit code: 0 if no cycles, 1 if circular dependencies found.
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")
447 "Audit pyproject.toml dependencies for unpinned version specifiers. "
448 "Exit 1 if any open >= / >-only ranges are found."
451 args = parser.parse_args(argv
or [])
454 return audit_pins(root, json_output=args.json)
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")
472 print(f
"\nCircular dependencies detected ({len(cycles)}):")
474 print(f
" {' -> '.join(cycle)}")
476 print(
"No circular dependencies found.")
479 has_deps = {m: d
for m, d
in graph.items()
if d}
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)}")
485 return 1
if cycles
else 0
str|None _extract_module_docstring(str source)
str|None entry_marker(str purpose)
list[SectionItem]|None dependency_items(str|None docstring)
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)
dict[str, list[str]] build_dependency_graph(Path root, *, bool eager_only=False)
list[str] extract_dependencies(str source, *, bool eager_only=False)
str to_dot(dict[str, list[str]] graph)
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)
list[tuple[str, bool]] dependency_entries(str|None docstring)
str to_mermaid(dict[str, list[str]] graph)
tuple[dict[str, list[str]], dict[str, list[str]]] build_dependency_graphs(Path root)