oct
Loading...
Searching...
No Matches
dependency_check.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/deps/dependency_check.py
4
5"""
6Purpose
7-------
8Compare a module's ``Dependencies`` docstring section with what the module
9actually imports. The advisory lint rule ``dependencies_match`` runs this
10on every source module. The section parsing lives in
11:mod:`oct.deps.oct_deps`, whose cycle detection leaves lazy and
12not-imported entries out, so the rule and ``oct deps`` read an entry alike.
13
14The operator ruled on 2026-09-28 (docs/CHART_C_TRACE_FOLLOWUPS_2026-09-28.md,
15section 8, in the workspace):
16
171. Every import is declared. An import made only inside a function or
18 under ``TYPE_CHECKING`` is declared with a purpose starting ``lazy``,
19 and ``oct deps`` leaves lazy entries out of its cycle detection.
202. First-party packages (any package in the workspace) and third-party
21 packages are both checked; standard-library modules are optional
22 (declaring one is allowed; it is stale only if nothing of it is
23 imported).
243. The rule runs at every profile, is advisory, and exempts test modules.
25
26One addition, for the operator to veto: a dependency used without an
27import (a CLI, a ``python -m`` subprocess, a pytest plugin, a module
28loaded by name) is declared with a purpose starting ``not imported``; it
29is never reported stale and never enters cycle detection.
30
31Responsibilities
32----------------
33- :func:`parse_entries` - the ``Dependencies`` entries of a docstring, with
34 their markers, and the lines that are not entries.
35- :func:`collect_imports` - every dependency a module imports, each marked
36 eager or lazy, standard library kept apart.
37- :func:`check_dependencies_match` - the comparison: missing, stale,
38 wrongly marked and unrecognised entries, as one verdict and message.
39
40Diagnostics
41-----------
42Domain: OCT-DEPS
43Levels:
44 L2 - (none) called once per file by the linter; the linter logs verdicts
45 L3 - workspace roots resolved for a project
46 L4 - per-file comparison totals
47
48Contracts
49---------
50- Never raises: any internal error becomes a passing verdict whose message
51 says the check did not run. A raising rule would make the quality gate
52 skip every rule for the file, the secret check included.
53- A module's dotted name is taken from the workspace subproject root whose
54 first path component is a package, so a file in a namespace directory of
55 a package (``oct/oct/core``) is ``oct.core.git``; outside any package,
56 from Python's own rule.
57- First-party names are checked against the filesystem with exact case
58 (directory listings memoised for one lint run), never by walking the
59 project, so ``oct git status`` stays fast and Windows and Linux agree.
60- An import is recorded as written: ``import a.b.c`` is ``a.b.c``; ``from X
61 import y`` is ``X.y`` when that is a module, else ``X``. Nothing is
62 shortened to whatever prefix exists on disk, so an absent generated
63 module (a setuptools-scm version file) does not change the verdict.
64- A first-party entry must name a module exactly, or exactly what an
65 import names; an attribute (``pkg.mod.Class``) is reported as naming no
66 module. A third-party or standard-library entry may name the package,
67 which covers its submodules.
68- An ``if TYPE_CHECKING:`` body is lazy and its ``else`` eager; ``if not
69 TYPE_CHECKING:`` is the reverse; any other test is eager.
70- An ``oc_diagnostics`` entry is satisfied by an import of oct's shim
71 ``oct.core.diagnostics`` (Core spec: oc_diagnostics "should always be
72 listed if the module uses _dbg").
73
74Dependencies
75------------
76- oct.core.diagnostics (_dbg structured logger)
77- oct.core.workspace (lazy; the workspace manifest's subproject roots)
78- oct.deps.import_graph (_is_test_path, _resolve_relative - shared rules)
79- oct.deps.oct_deps (module_name_for, dependency_items, entry_marker - the shared parsing)
80"""
81
82from __future__ import annotations
83
84import ast
85import os
86import re
87import sys
88from dataclasses import dataclass, field
89from pathlib import Path
90
91from oct.core.diagnostics import _dbg
92from oct.deps.import_graph import _is_test_path, _resolve_relative
93from oct.deps.oct_deps import dependency_items, entry_marker, module_name_for
94
95#: ``name (purpose)``; the name may be wrapped in backticks.
96_ENTRY_RE = re.compile(r"^`?([A-Za-z_][\w.]*)`?\s*\‍((.*)\‍)\s*$")
97
98STDLIB: frozenset[str] = frozenset(sys.stdlib_module_names) | {"__future__"}
99
100#: The shim through which oct modules reach oc_diagnostics.
101_SHIM = "oct.core.diagnostics"
102
103#: Directories that never hold an importable namespace portion.
104_NOT_NAMESPACE = frozenset({"tests", "test", "docs", "__pycache__", "node_modules",
105 ".venv", "build", "dist"})
106
107
108@dataclass(frozen=True)
109class Entry:
110 """One ``- name (purpose)`` line of a Dependencies section."""
111
112 name: str
113 purpose: str
114 lazy: bool
115 not_imported: bool
116
117
118@dataclass
120 """What :func:`check_dependencies_match` found for one module."""
121
122 missing: list[str] = field(default_factory=list)
123 missing_lazy: list[str] = field(default_factory=list)
124 unmarked_lazy: list[str] = field(default_factory=list)
125 marked_lazy_but_eager: list[str] = field(default_factory=list)
126 marked_not_imported_but_imported: list[str] = field(default_factory=list)
127 stale: list[str] = field(default_factory=list)
128 unrecognised: list[str] = field(default_factory=list)
129 unresolvable: list[str] = field(default_factory=list)
130
131 @property
132 def ok(self) -> bool:
133 return not any(vars(self).values())
134
135 def message(self) -> str:
136 parts = []
137 for label, items in (
138 ("missing", self.missing),
139 ("missing, declare as lazy", self.missing_lazy),
140 ("imported only lazily, mark the entry 'lazy'", self.unmarked_lazy),
141 ("marked lazy but imported at top level", self.marked_lazy_but_eager),
142 ("marked 'not imported' but imported", self.marked_not_imported_but_imported),
143 ("stale (not imported)", self.stale),
144 ("names no module", self.unresolvable),
145 ("not a '- name (purpose)' entry", self.unrecognised),
146 ):
147 if items:
148 parts.append(f"{label}: {', '.join(items)}")
149 return "; ".join(parts) or "OK"
150
151
152def parse_entries(docstring: str) -> tuple[list[Entry], list[str], bool]:
153 """Return ``(entries, unrecognised lines, section present)`` for *docstring*.
154
155 The items come from :func:`oct.deps.oct_deps.dependency_items`: a bullet
156 whose text continues on the next line is joined first, so a wrapped
157 purpose still parses and its marker is read from the joined text, as
158 ``oct deps`` reads it.
159 """
160 items = dependency_items(docstring)
161 if items is None:
162 return [], [], bool(re.search(r"Dependencies\s*\n\s*-+", docstring or ""))
163 entries, unrecognised = [], []
164 for item in items:
165 e = _ENTRY_RE.match(item.text)
166 if not e:
167 text = item.text
168 unrecognised.append(text if len(text) <= 60 else text[:57] + "...")
169 continue
170 marker = entry_marker(e.group(2))
171 entries.append(Entry(e.group(1), e.group(2), marker == "lazy", marker == "not imported"))
172 return entries, unrecognised, True
173
174
175# -- the filesystem ------------------------------------------------------------------------
176
177
178class _Probe:
179 """Exact-case filesystem probes, memoised in a dict the caller owns.
180
181 The linter passes ``LinterContext.dependency_memo``, which lives exactly
182 as long as one lint run, so a long-lived process (the MCP server) sees
183 files created between runs, and nothing needs resetting. The memo is
184 shared by the linter's worker threads; a race only repeats a probe.
185 """
186
187 def __init__(self, memo: dict) -> None:
188 self.memo = memo
189 self._listings: dict[Path, frozenset[str]] = memo.setdefault("listings", {})
190 self._modules: dict[tuple[Path, str, bool], bool] = memo.setdefault("modules", {})
191
192 def listing(self, directory: Path) -> frozenset[str]:
193 got = self._listings.get(directory)
194 if got is None:
195 try:
196 got = frozenset(os.listdir(directory))
197 except OSError:
198 got = frozenset()
199 self._listings[directory] = got
200 return got
201
202 def is_dir(self, parent: Path, name: str) -> bool:
203 return name in self.listing(parent) and (parent / name).is_dir()
204
205 def is_package(self, directory: Path) -> bool:
206 return "__init__.py" in self.listing(directory)
207
208 def module_exists(self, root: Path, dotted: str, namespace_top: bool = False) -> bool:
209 """Whether *dotted* is a module under *root*, with exact case.
210
211 A directory counts as a (namespace) package below the top level; at
212 the top level only with an ``__init__.py``, unless *namespace_top*.
213 """
214 key = (root, dotted, namespace_top)
215 got = self._modules.get(key)
216 if got is None:
217 got = self._module_exists(root, dotted, namespace_top)
218 self._modules[key] = got
219 return got
220
221 def _module_exists(self, root: Path, dotted: str, namespace_top: bool) -> bool:
222 parts = dotted.split(".")
223 d = root
224 for part in parts[:-1]:
225 if not self.is_dir(d, part):
226 return False
227 d = d / part
228 leaf = parts[-1]
229 if leaf + ".py" in self.listing(d):
230 return True
231 if self.is_dir(d, leaf):
232 return self.is_package(d / leaf) or len(parts) > 1 or namespace_top
233 return False
234
235
236def _workspace_roots(project_root: Path, probe: _Probe) -> tuple[Path, ...]:
237 """Every subproject root of the workspace holding *project_root*, then it."""
238 func = "_workspace_roots"
239 cached = probe.memo.setdefault("roots", {}).get(project_root)
240 if cached is not None:
241 return cached
242 roots: list[Path] = [project_root]
243 try:
244 from oct.core.workspace import find_workspace_root, load_workspace
245 ws_root = find_workspace_root(project_root)
246 if ws_root is not None:
247 roots = [sp.path.resolve() for sp in load_workspace(ws_root).subprojects] + roots
248 except Exception as exc: # a broken manifest must not stop the lint
249 _dbg("OCT-DEPS", func, f"workspace roots unavailable ({exc!r}); project only", 3)
250 ordered = tuple(sorted(dict.fromkeys(roots), key=lambda r: len(r.parts), reverse=True))
251 _dbg("OCT-DEPS", func, f"{project_root}: {len(ordered)} roots", 3)
252 probe.memo["roots"][project_root] = ordered
253 return ordered
254
255
256def _naming_root(py_file: Path, roots: tuple[Path, ...], probe: _Probe) -> tuple[Path, bool]:
257 """The directory *py_file*'s dotted name is relative to, and whether it is packaged.
258
259 Preferred: the most specific workspace root under which the file's first
260 path component is a package. Otherwise Python's rule from the file's
261 own directory; ``False`` then means a script outside any package.
262 """
263 for r in roots:
264 try:
265 rel = py_file.relative_to(r)
266 except ValueError:
267 continue
268 if len(rel.parts) > 1 and probe.is_package(r / rel.parts[0]):
269 return r, True
270 d = py_file.parent
271 if not probe.is_package(d):
272 return d, False
273 while d.parent != d and probe.is_package(d.parent):
274 d = d.parent
275 return d.parent, True
276
277
279 """First-party names: workspace packages, plus a script's local modules.
280
281 *local_roots* are the directories a script outside any package can
282 import from: its own directory (``sys.path[0]``) and, as the runners
283 in this workspace arrange, the one above. Their modules and their
284 namespace directories (holding ``.py`` files, not tests or docs) are
285 first-party.
286 """
287
288 def __init__(self, roots: tuple[Path, ...], local_roots: tuple[Path, ...],
289 probe: _Probe) -> None:
290 self.roots = roots
291 self.local_roots = local_roots
292 self.probe = probe
293 self.tops: set[str] = set()
294 for r in roots:
295 self.tops.update(n for n in probe.listing(r) if probe.is_package(r / n))
296 for r in local_roots:
297 for n in probe.listing(r):
298 if n.endswith(".py"):
299 self.tops.add(n[:-3])
300 elif (n not in _NOT_NAMESPACE and not n.startswith(".") and probe.is_dir(r, n)
301 and any(x.endswith(".py") for x in probe.listing(r / n))):
302 self.tops.add(n)
303
304 def is_first_party(self, dotted: str) -> bool:
305 return dotted.split(".")[0] in self.tops
306
307 def exists(self, dotted: str) -> bool:
308 return (any(self.probe.module_exists(r, dotted) for r in self.roots)
309 or any(self.probe.module_exists(r, dotted, True) for r in self.local_roots))
310
311
312def _resolver_for(roots: tuple[Path, ...], local_roots: tuple[Path, ...],
313 probe: _Probe) -> _Resolver:
314 resolvers = probe.memo.setdefault("resolvers", {})
315 got = resolvers.get((roots, local_roots))
316 if got is None:
317 got = resolvers[(roots, local_roots)] = _Resolver(roots, local_roots, probe)
318 return got
319
320
321# -- imports ----------------------------------------------------------------------------------
322
323
324def _type_checking_kind(test: ast.expr) -> str | None:
325 """``"body"`` for ``if TYPE_CHECKING:``, ``"orelse"`` for ``if not TYPE_CHECKING:``."""
326 def is_tc(node: ast.expr) -> bool:
327 return ((isinstance(node, ast.Name) and node.id == "TYPE_CHECKING")
328 or (isinstance(node, ast.Attribute) and node.attr == "TYPE_CHECKING"))
329 if is_tc(test):
330 return "body"
331 if isinstance(test, ast.UnaryOp) and isinstance(test.op, ast.Not) and is_tc(test.operand):
332 return "orelse"
333 return None
334
335
336def _imports_with_laziness(tree: ast.Module) -> list[tuple[ast.Import | ast.ImportFrom, bool]]:
337 """Every import statement with whether it is lazy, in one linear pass."""
338 out: list[tuple[ast.Import | ast.ImportFrom, bool]] = []
339 stack: list[tuple[ast.AST, bool]] = [(stmt, False) for stmt in tree.body]
340 while stack:
341 node, lazy = stack.pop()
342 if isinstance(node, (ast.Import, ast.ImportFrom)):
343 out.append((node, lazy))
344 elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
345 stack.extend((child, True) for child in node.body)
346 elif isinstance(node, ast.If):
347 kind = _type_checking_kind(node.test)
348 stack.extend((child, lazy or kind == "body") for child in node.body)
349 stack.extend((child, lazy or kind == "orelse") for child in node.orelse)
350 else:
351 stack.extend((child, lazy) for child in ast.iter_child_nodes(node)
352 if isinstance(child, (ast.stmt, ast.excepthandler, ast.match_case)))
353 return out
354
355
356def collect_imports(tree: ast.Module, module: str, is_package: bool,
357 resolver: _Resolver) -> tuple[dict[str, bool], set[str]]:
358 """``(non-stdlib dependency -> imported eagerly, standard-library names)``.
359
360 Names are recorded as written: ``import a.b`` is ``a.b``; ``from X import
361 y`` is ``X.y`` when that is a first-party module, else ``X``.
362 """
363 found: dict[str, bool] = {}
364 stdlib: set[str] = set()
365
366 def add(name: str, eager: bool) -> None:
367 if name and name != module:
368 found[name] = found.get(name, False) or eager
369
370 for node, lazy in _imports_with_laziness(tree):
371 eager = not lazy
372 if isinstance(node, ast.Import):
373 for alias in node.names:
374 if alias.name.split(".")[0] in STDLIB:
375 stdlib.add(alias.name)
376 else:
377 add(alias.name, eager)
378 continue
379 if node.level:
380 base = _resolve_relative(module, is_package, node.level, node.module)
381 if base is None:
382 continue
383 else:
384 base = node.module or ""
385 if not base:
386 continue
387 if base.split(".")[0] in STDLIB:
388 stdlib.add(base)
389 continue
390 if resolver.is_first_party(base):
391 for alias in node.names:
392 sub = f"{base}.{alias.name}"
393 add(sub if alias.name != "*" and resolver.exists(sub) else base, eager)
394 else:
395 add(base, eager)
396 return found, stdlib
397
398
399# -- the check ------------------------------------------------------------------------------------
400
401
402def _covers(entry: str, dep: str, first_party: bool) -> bool:
403 if entry == dep:
404 return True
405 return not first_party and dep.startswith(entry + ".")
406
407
408def _is_test_module(py_file: Path, naming_root: Path, project_root: Path) -> bool:
409 return (_is_test_path(py_file, naming_root) or _is_test_path(py_file, project_root)
410 or py_file.name.startswith("test_") or py_file.name == "conftest.py")
411
412
413def _stdlib_satisfied(entry: str, stdlib: set[str]) -> bool:
414 """A declared standard-library entry is satisfied by an import of it,
415 of a submodule, or of its package (``import os`` gives ``os.path``)."""
416 return any(_covers(entry, s, False) or _covers(s, entry, False) for s in stdlib)
417
418
419def check_dependencies_match(py_file: Path, project_root: Path, tree: ast.Module | None,
420 docstring: str | None = None, *,
421 memo: dict | None = None) -> tuple[bool, str]:
422 """The ``dependencies_match`` verdict for one file: ``(ok, message)``.
423
424 *docstring* defaults to the module docstring of *tree*. *memo* holds the
425 filesystem probes across calls (the linter passes one per run); without
426 it every call probes afresh. Test modules are exempt. Never raises.
427 """
428 func = "check_dependencies_match"
429 try:
430 probe = _Probe(memo if memo is not None else {})
431 py_file = py_file.resolve()
432 project_root = project_root.resolve()
433 roots = _workspace_roots(project_root, probe)
434 naming, packaged = _naming_root(py_file, roots, probe)
435 if _is_test_module(py_file, naming, project_root):
436 return True, "exempt: test module"
437 if tree is None:
438 return True, "not run: the file does not parse (syntax_error reports it)"
439 if docstring is None:
440 docstring = ast.get_docstring(tree, clean=False) or ""
441 local = () if packaged and naming in roots else tuple(dict.fromkeys((naming, naming.parent)))
442 resolver = _resolver_for(roots if naming in roots else roots + (naming,), local, probe)
443 module = module_name_for(py_file, naming)
444 imports, stdlib = collect_imports(tree, module, py_file.name == "__init__.py", resolver)
445 entries, unrecognised, _present = parse_entries(docstring)
446
447 f = Findings(unrecognised=unrecognised)
448 declared: list[tuple[Entry, bool]] = []
449 for e in entries:
450 fp = resolver.is_first_party(e.name)
451 if fp and not e.not_imported and e.name not in imports and not resolver.exists(e.name):
452 f.unresolvable.append(e.name)
453 continue
454 declared.append((e, fp))
455
456 # A package entry can cover an eager import (click) and a lazy one
457 # (click.utils) at once; the lazy one then needs no marker.
458 covers_eager = {
459 id(e): any(imports[d] and _covers(e.name, d, fp) for d in imports)
460 for e, fp in declared
461 }
462 for dep, eager in sorted(imports.items()):
463 covering = [e for e, fp in declared if _covers(e.name, dep, fp)]
464 if covering and all(e.not_imported for e in covering):
465 f.marked_not_imported_but_imported.append(dep)
466 elif not covering:
467 (f.missing if eager else f.missing_lazy).append(dep)
468 elif not eager and not any(e.lazy or e.not_imported or covers_eager[id(e)] for e in covering):
469 f.unmarked_lazy.append(dep)
470 elif eager and all(e.lazy for e in covering):
471 f.marked_lazy_but_eager.append(dep)
472 for e, fp in declared:
473 if e.not_imported:
474 continue
475 if any(_covers(e.name, dep, fp) for dep in imports):
476 continue
477 if e.name.split(".")[0] in STDLIB and _stdlib_satisfied(e.name, stdlib):
478 continue
479 if e.name == "oc_diagnostics" and _SHIM in imports:
480 continue
481 f.stale.append(e.name)
482 _dbg("OCT-DEPS", func, f"{module}: imports={len(imports)} entries={len(entries)} ok={f.ok}", 4)
483 return f.ok, f.message()
484 except Exception as exc: # never raise: see Contracts
485 return True, f"not run: internal error ({type(exc).__name__}: {exc})"
486
487
488__all__ = [
489 "Entry",
490 "Findings",
491 "STDLIB",
492 "check_dependencies_match",
493 "collect_imports",
494 "parse_entries",
495]
bool _module_exists(self, Path root, str dotted, bool namespace_top)
dict[tuple[Path, str, bool], bool] _modules
dict[Path, frozenset[str]] _listings
frozenset[str] listing(self, Path directory)
bool is_package(self, Path directory)
bool is_dir(self, Path parent, str name)
bool module_exists(self, Path root, str dotted, bool namespace_top=False)
None __init__(self, tuple[Path,...] roots, tuple[Path,...] local_roots, _Probe probe)
tuple[Path,...] _workspace_roots(Path project_root, _Probe probe)
bool _covers(str entry, str dep, bool first_party)
tuple[dict[str, bool], set[str]] collect_imports(ast.Module tree, str module, bool is_package, _Resolver resolver)
bool _is_test_module(Path py_file, Path naming_root, Path project_root)
bool _stdlib_satisfied(str entry, set[str] stdlib)
list[tuple[ast.Import|ast.ImportFrom, bool]] _imports_with_laziness(ast.Module tree)
tuple[bool, str] check_dependencies_match(Path py_file, Path project_root, ast.Module|None tree, str|None docstring=None, *, dict|None memo=None)
tuple[list[Entry], list[str], bool] parse_entries(str docstring)
tuple[Path, bool] _naming_root(Path py_file, tuple[Path,...] roots, _Probe probe)
_Resolver _resolver_for(tuple[Path,...] roots, tuple[Path,...] local_roots, _Probe probe)
str|None _type_checking_kind(ast.expr test)