oc_chat
Loading...
Searching...
No Matches
package_walk.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/testing/package_walk.py
4
5"""
6Single-package fallback discovery + the undeclared-root scan (Plan 184).
7
8Purpose
9-------
10Implement ADR-T-12 (walk upward from cwd for ``pyproject.toml`` or
11``pytest.ini``, cwd plus up to four ancestors; basename package-id with
12the ``[tool.oct.test].package_id`` override) and the spec-10 §10
13undeclared-root scan that kills the A-18 testpaths trap: a directory
14holding test files outside the effective roots is a finding — fatal in
15strict, warning in smoke/full.
16
17Responsibilities
18----------------
19- ``walk_for_package`` — discovery; testpaths from ``pytest.ini`` (or
20 ``[tool.pytest.ini_options]``), package-root fallback when absent.
21- ``scan_undeclared_roots`` — the §10 filesystem scan with the
22 nested-package boundary rule: a directory carrying its own
23 ``pytest.ini``/``pyproject.toml``/``tox.ini``/``setup.cfg`` is a
24 DIFFERENT package and is skipped, unless a declared root lies within
25 it (the live-tree shape: the product dir carries ``pyproject.toml``
26 AND contains the declared roots).
27
28Diagnostics
29-----------
30Domain: OC_TESTING
31Levels:
32 L2 — lifecycle events
33 L3 — semantic details
34 L4 — deep tracing
35
36Contracts
37---------
38- Discovery is read-only; nothing is created or mutated.
39- Hidden directories, ``__pycache__``, and ``node_modules`` are never
40 scanned.
41- The scan honours the package's ``python_files`` pattern (default
42 ``test_*.py``).
43
44Dependencies
45------------
46- oc_diagnostics (structured debug logging via _dbg)
47- oc_chat.testing.report (error constructors)
48"""
49from __future__ import annotations
50
51import configparser
52from dataclasses import dataclass, field
53import fnmatch
54import os
55from pathlib import Path
56import tomllib
57
58from oc_diagnostics import _dbg
59
60from oc_chat.testing.report import make_error
61
62_WALK_LEVELS = 4 # cwd plus up to four ancestors (ADR-T-12)
63
64_CONFIG_NAMES = ("pyproject.toml", "pytest.ini")
65_BOUNDARY_NAMES = ("pytest.ini", "pyproject.toml", "tox.ini", "setup.cfg")
66_SKIP_DIR_NAMES = {"__pycache__", "node_modules"}
67
68_CWD_RESOLUTION_MESSAGE = (
69 "oct test: no pyproject.toml or pytest.ini found within 4 parent "
70 "levels of cwd. Run from inside a workspace or package, or pass "
71 "--package NAME to disambiguate."
72)
73
74
75@dataclass
77 """The resolved single-package fallback discovery."""
78
79 root: Path
80 config_file: Path
81 package_id: str
82 testpaths: list[str] = field(default_factory=list)
83 roots: list[Path] = field(default_factory=list)
84 python_files: str = "test_*.py"
85 profile_pin: str | None = None
86
87
88def _read_pyproject(root: Path) -> dict:
89 """Parse ``pyproject.toml`` at *root*; empty dict on absence/error."""
90 func = "_read_pyproject"
91 path = root / "pyproject.toml"
92 if not path.is_file():
93 return {}
94 try:
95 with path.open("rb") as fh:
96 return tomllib.load(fh)
97 except (OSError, tomllib.TOMLDecodeError) as exc:
98 _dbg("OC_TESTING", func, f"unreadable pyproject: {exc}", 2)
99 return {}
100
101
102def _read_pytest_ini(root: Path) -> tuple[list[str], str | None]:
103 """Return (testpaths, python_files) declared in ``pytest.ini``."""
104 func = "_read_pytest_ini"
105 path = root / "pytest.ini"
106 if not path.is_file():
107 return [], None
108 parser = configparser.ConfigParser()
109 try:
110 parser.read(path, encoding="utf-8")
111 except (OSError, configparser.Error) as exc:
112 _dbg("OC_TESTING", func, f"unreadable pytest.ini: {exc}", 2)
113 return [], None
114 if not parser.has_section("pytest"):
115 return [], None
116 testpaths = parser.get("pytest", "testpaths", fallback="").split()
117 python_files = parser.get("pytest", "python_files", fallback=None)
118 return testpaths, python_files
119
120
121def walk_for_package(cwd: Path) -> tuple[WalkResult | None, dict | None]:
122 """ADR-T-12 discovery: walk upward from *cwd* for a package config.
123
124 Returns ``(result, None)`` or ``(None, error)`` where *error* is the
125 ``CWD_RESOLUTION_FAILED`` entry with the pinned message.
126 """
127 func = "walk_for_package"
128 current = Path(cwd).resolve()
129 candidates = [current, *current.parents[:_WALK_LEVELS]]
130 root: Path | None = None
131 config_file: Path | None = None
132 for candidate in candidates:
133 for name in _CONFIG_NAMES:
134 if (candidate / name).is_file():
135 root = candidate
136 config_file = candidate / name
137 break
138 if root is not None:
139 break
140 if root is None or config_file is None:
141 _dbg("OC_TESTING", func, f"resolution failed from {cwd}", 2)
142 return None, make_error(
143 "configuration", "CWD_RESOLUTION_FAILED", "fatal",
144 _CWD_RESOLUTION_MESSAGE,
145 )
146
147 testpaths, python_files = _read_pytest_ini(root)
148 pyproject = _read_pyproject(root)
149 if not testpaths:
150 ini_options = pyproject.get("tool", {}).get("pytest", {}).get(
151 "ini_options", {})
152 declared = ini_options.get("testpaths", [])
153 if isinstance(declared, str):
154 declared = declared.split()
155 testpaths = [str(p) for p in declared]
156 if python_files is None:
157 pf = ini_options.get("python_files")
158 if isinstance(pf, list):
159 python_files = " ".join(pf)
160 elif isinstance(pf, str):
161 python_files = pf
162
163 oct_test = pyproject.get("tool", {}).get("oct", {}).get("test", {})
164 package_id = str(oct_test.get("package_id") or root.name)
165 profile_pin = oct_test.get("profile")
166
167 roots = [root / p for p in testpaths] if testpaths else [root]
168 result = WalkResult(
169 root=root,
170 config_file=config_file,
171 package_id=package_id,
172 testpaths=testpaths,
173 roots=roots,
174 python_files=python_files or "test_*.py",
175 profile_pin=str(profile_pin) if profile_pin else None,
176 )
177 _dbg("OC_TESTING", func,
178 f"root={root} id={package_id} roots={len(roots)}", 3)
179 return result, None
180
181
182def _matches_any(name: str, patterns: list[str]) -> bool:
183 return any(fnmatch.fnmatch(name, pat) for pat in patterns)
184
185
187 root: Path,
188 declared: list[Path],
189 python_files: str = "test_*.py",
190) -> list[Path]:
191 """§10 scan: directories under *root* holding test files outside the
192 *declared* roots.
193
194 The nested-package boundary rule: a non-root directory carrying its
195 own package config is a different package and is pruned, unless a
196 declared root lies at-or-under it.
197 """
198 func = "scan_undeclared_roots"
199 root = Path(root).resolve()
200 declared_resolved = [Path(d).resolve() for d in declared]
201 patterns = python_files.split()
202 findings: set[Path] = set()
203
204 def _under(path: Path, ancestor: Path) -> bool:
205 return path == ancestor or ancestor in path.parents
206
207 for dirpath, dirnames, filenames in os.walk(root):
208 current = Path(dirpath)
209 keep: list[str] = []
210 for name in sorted(dirnames):
211 child = current / name
212 if name.startswith(".") or name in _SKIP_DIR_NAMES:
213 continue
214 if child != root and any(
215 (child / marker).is_file() for marker in _BOUNDARY_NAMES
216 ) and not any(_under(d, child) for d in declared_resolved):
217 _dbg("OC_TESTING", func, f"boundary pruned: {child}", 4)
218 continue
219 keep.append(name)
220 dirnames[:] = keep
221
222 if any(_under(current, d) for d in declared_resolved):
223 continue
224 if any(_matches_any(f, patterns) for f in filenames):
225 findings.add(current)
226
227 result = sorted(findings)
228 _dbg("OC_TESTING", func, f"{len(result)} undeclared root(s)", 3)
229 return result
230
231
232_dbg("OC_TESTING", "module", "loaded", 4)
233
234__all__ = ["WalkResult", "walk_for_package", "scan_undeclared_roots"]
dict _read_pyproject(Path root)
tuple[list[str], str|None] _read_pytest_ini(Path root)
tuple[WalkResult|None, dict|None] walk_for_package(Path cwd)
list[Path] scan_undeclared_roots(Path root, list[Path] declared, str python_files="test_*.py")
bool _matches_any(str name, list[str] patterns)