oct
Loading...
Searching...
No Matches
discovery.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/testing/discovery.py
4
5"""
6Purpose
7-------
8The spec-10 §9 discovery-precedence layer for the ``oct test`` v1.0
9seed (Plan 189): workspace / ``--package P`` / bare selection over the
10workspace manifest, plus the ADR-T-12 single-package fallback walk
11(cwd upward for ``pyproject.toml``/``pytest.ini``, four-parent-level
12cap, basename inference with the ``[tool.oct.test].package_id``
13cross-check override).
14
15Responsibilities
16----------------
17- :func:`discover` - the §9 precedence table, row for row. Flags are
18 plain keyword arguments (this is a LIBRARY: no CLI, no flag
19 parsing, no ``sys.exit``); every exit-4 table branch raises
20 :class:`oct.testing.errors.OctTestError` with its §12.4 code.
21- :func:`walk_for_package` - the ADR-T-12 walk with the ADR's pinned
22 diagnostic message, verbatim.
23- OUT of scope (future slices): pytest collection and the §4
24 collection-check routing (``COLLECTION_ERROR`` /
25 ``NO_TESTS_COLLECTED``), runners, schedulers, reports.
26
27Diagnostics
28-----------
29Domain: OCT-TESTING
30Levels:
31 L2: lifecycle (discovery start / resolution failures)
32 L3: semantic details (resolved mode, selection, walk result)
33 L4: deep tracing
34
35Contracts
36---------
37- Discovery is READ-ONLY: nothing is created or mutated.
38- Mutual exclusion (``workspace`` + ``package``) is checked before
39 any filesystem access - a fully valid workspace does not rescue
40 the call (§9 row 7, "Either").
41- The ``--package`` fallback (no manifest found) does NOT walk: the
42 package roots at cwd and ``P`` must equal the cwd basename (§9
43 row 6); the ADR-T-12 walk belongs to the bare row only.
44- A corrupt ``pyproject.toml`` at a discovered root degrades to
45 basename inference - the file's existence marks the package root;
46 only the optional override read is tolerant.
47
48Dependencies
49------------
50- oct.core.workspace (read-only: find_workspace_root)
51- oct.core.diagnostics (_dbg)
52- oct.testing.errors (the §12.4 taxonomy as data)
53- oct.testing.manifest (the §9 manifest test view)
54"""
55
56from __future__ import annotations
57
58import tomllib
59from dataclasses import dataclass, field
60from pathlib import Path
61
62from oct.core.diagnostics import _dbg
63from oct.core.workspace import WORKSPACE_MANIFEST, find_workspace_root
64from oct.testing.errors import (
65 CWD_RESOLUTION_FAILED,
66 OctTestError,
67 PACKAGE_UNKNOWN,
68 WORKSPACE_AND_PACKAGE_BOTH_PASSED,
69 WORKSPACE_FILE_NOT_FOUND,
70)
71from oct.testing.manifest import (
72 PackageTestConfig,
73 TestManifest,
74 load_test_manifest,
75)
76
77
78#: Workspace mode: selection comes from the manifest test blocks.
79MODE_WORKSPACE: str = "workspace"
80
81#: Single-package fallback mode (§9 rows 2 and 6).
82MODE_SINGLE_PACKAGE: str = "single_package"
83
84#: ADR-T-12 cap: cwd plus up to four parent levels.
85WALK_LEVELS: int = 4
86
87#: Config files the ADR-T-12 walk recognises, preference order -
88#: ``pyproject.toml`` first, so the clause-5 ``package_id``
89#: cross-check always reads the discovered file.
90_CONFIG_FILE_NAMES: tuple[str, ...] = ("pyproject.toml", "pytest.ini")
91
92#: The ADR-T-12 diagnostic message, pinned VERBATIM by the ADR
93#: (message recut in spec v0.4, review B P3-01).
94CWD_RESOLUTION_MESSAGE: str = (
95 "oct test: no pyproject.toml or pytest.ini found within 4 parent "
96 "levels of cwd. Run from inside a workspace or package, or pass "
97 "--package NAME to disambiguate."
98)
99
100
101@dataclass(frozen=True)
103 """The resolved single-package fallback target.
104
105 Attributes
106 ----------
107 root
108 Absolute package root: the config file's directory (ADR-T-12
109 walk, §9 row 2) or cwd itself (``--package`` fallback, row 6).
110 package_id
111 Basename of *root*, unless the discovered ``pyproject.toml``
112 carries a ``[tool.oct.test].package_id`` override (clause 5),
113 or ``--package`` supplied the id (row 6).
114 config_file
115 The discovered config file; ``None`` in the ``--package``
116 fallback, which performs no walk.
117 """
118
119 root: Path
120 package_id: str
121 config_file: Path | None = None
122
123
124@dataclass(frozen=True)
126 """Outcome of the §9 discovery precedence.
127
128 Attributes
129 ----------
130 mode
131 :data:`MODE_WORKSPACE` or :data:`MODE_SINGLE_PACKAGE`.
132 workspace_root
133 Resolved workspace root (workspace mode only).
134 manifest
135 The parsed :class:`~oct.testing.manifest.TestManifest`
136 (workspace mode only).
137 selected
138 The selected packages, manifest order: every test-enabled
139 package, or exactly the ``--package`` target. Empty in
140 single-package mode.
141 single
142 The :class:`SinglePackage` target (single-package mode only).
143 """
144
145 mode: str
146 workspace_root: Path | None = None
147 manifest: TestManifest | None = None
148 selected: tuple[PackageTestConfig, ...] = field(default_factory=tuple)
149 single: SinglePackage | None = None
150
151
153 cwd: Path,
154 *,
155 workspace: bool = False,
156 package: str | None = None,
157) -> DiscoveryResult:
158 """Resolve the §9 discovery precedence from *cwd*.
159
160 Parameters
161 ----------
162 cwd
163 Directory the (future) invocation runs from; the upward
164 manifest search and the ADR-T-12 walk both start here.
165 workspace
166 Library mirror of the ``--workspace`` flag.
167 package
168 Library mirror of the ``--package P`` flag.
169
170 Returns
171 -------
172 :class:`DiscoveryResult`
173
174 Raises
175 ------
176 OctTestError
177 Every exit-4 branch of the §9 table:
178 ``WORKSPACE_AND_PACKAGE_BOTH_PASSED`` (row 7, checked before
179 any IO), ``WORKSPACE_FILE_NOT_FOUND`` (row 4),
180 ``PACKAGE_UNKNOWN`` (rows 5 and 6),
181 ``CWD_RESOLUTION_FAILED`` (row 2), and the manifest ``9101``/
182 ``9102`` codes when a found workspace file is invalid
183 (rows 1/3/5).
184 """
185 func = "discover"
186 if workspace and package is not None:
187 raise OctTestError(
188 WORKSPACE_AND_PACKAGE_BOTH_PASSED,
189 "--workspace and --package are mutually exclusive",
190 )
191
192 start = Path(cwd)
193 _dbg(
194 "OCT-TESTING", func,
195 f"cwd={start} workspace={workspace} package={package!r}", 2,
196 )
197 workspace_root = find_workspace_root(start)
198
199 if workspace:
200 if workspace_root is None:
201 raise OctTestError(
202 WORKSPACE_FILE_NOT_FOUND,
203 f"--workspace passed but no {WORKSPACE_MANIFEST} "
204 f"exists in {start} or any parent",
205 )
206 return _workspace_result(workspace_root, None)
207
208 if package is not None:
209 if workspace_root is not None:
210 return _workspace_result(workspace_root, package)
211 resolved = start.resolve()
212 if package != resolved.name:
213 raise OctTestError(
214 PACKAGE_UNKNOWN,
215 f"--package {package!r} does not match the cwd "
216 f"basename {resolved.name!r} and no "
217 f"{WORKSPACE_MANIFEST} was found",
218 )
219 _dbg("OCT-TESTING", func, f"single-package at cwd {resolved}", 3)
220 return DiscoveryResult(
221 mode=MODE_SINGLE_PACKAGE,
222 single=SinglePackage(
223 root=resolved, package_id=package, config_file=None,
224 ),
225 )
226
227 if workspace_root is not None:
228 return _workspace_result(workspace_root, None)
229 return DiscoveryResult(
230 mode=MODE_SINGLE_PACKAGE,
231 single=walk_for_package(start),
232 )
233
234
236 workspace_root: Path,
237 package: str | None,
238) -> DiscoveryResult:
239 """Build the workspace-mode result (§9 rows 1, 3, 5)."""
240 func = "_workspace_result"
241 manifest = load_test_manifest(workspace_root)
242
243 if package is None:
244 selected = manifest.enabled_packages
245 else:
246 target = manifest.packages_by_name.get(package)
247 if target is None or not target.enabled:
248 raise OctTestError(
249 PACKAGE_UNKNOWN,
250 f"package {package!r} is not listed and test-enabled "
251 f"in {manifest.workspace.root / WORKSPACE_MANIFEST}",
252 )
253 selected = (target,)
254
255 _dbg(
256 "OCT-TESTING", func,
257 f"workspace mode: {len(selected)} package(s) selected", 3,
258 )
259 return DiscoveryResult(
260 mode=MODE_WORKSPACE,
261 workspace_root=manifest.workspace.root,
262 manifest=manifest,
263 selected=selected,
264 )
265
266
267def walk_for_package(cwd: Path) -> SinglePackage:
268 """ADR-T-12: walk upward from *cwd* for a package config file.
269
270 Checks *cwd* plus up to :data:`WALK_LEVELS` ancestors for
271 ``pyproject.toml`` or ``pytest.ini`` (preference order within a
272 directory); the nearest hit wins. The package id is the config
273 directory's basename unless the discovered ``pyproject.toml``
274 carries a ``[tool.oct.test].package_id`` override (clause 5).
275
276 Raises
277 ------
278 OctTestError
279 ``CWD_RESOLUTION_FAILED`` with the ADR's pinned message when
280 the walk exhausts its levels.
281 """
282 func = "walk_for_package"
283 current = Path(cwd).resolve()
284 candidates = [current, *current.parents[:WALK_LEVELS]]
285 for candidate in candidates:
286 for name in _CONFIG_FILE_NAMES:
287 config_file = candidate / name
288 if config_file.is_file():
289 package_id = _package_id_for(candidate, config_file)
290 _dbg(
291 "OCT-TESTING", func,
292 f"root={candidate} id={package_id!r} "
293 f"config={name}",
294 3,
295 )
296 return SinglePackage(
297 root=candidate,
298 package_id=package_id,
299 config_file=config_file,
300 )
301 _dbg("OCT-TESTING", func, f"resolution failed from {cwd}", 2)
302 raise OctTestError(CWD_RESOLUTION_FAILED, CWD_RESOLUTION_MESSAGE)
303
304
305def _package_id_for(root: Path, config_file: Path) -> str:
306 """Infer the package id for *root* (ADR-T-12 clauses 4-5)."""
307 func = "_package_id_for"
308 if config_file.name != "pyproject.toml":
309 return root.name
310 try:
311 with config_file.open("rb") as fh:
312 data = tomllib.load(fh)
313 except (OSError, tomllib.TOMLDecodeError) as exc:
314 _dbg(
315 "OCT-TESTING", func,
316 f"unreadable pyproject at {root}: {exc}; basename stands",
317 2,
318 )
319 return root.name
320
321 node: object = data
322 for key in ("tool", "oct", "test"):
323 if not isinstance(node, dict):
324 return root.name
325 node = node.get(key)
326 if not isinstance(node, dict):
327 return root.name
328 override = node.get("package_id")
329 if isinstance(override, str) and override.strip():
330 _dbg(
331 "OCT-TESTING", func,
332 f"package_id override {override!r} at {root}", 4,
333 )
334 return override
335 return root.name
336
337
338__all__ = [
339 "CWD_RESOLUTION_MESSAGE",
340 "DiscoveryResult",
341 "MODE_SINGLE_PACKAGE",
342 "MODE_WORKSPACE",
343 "SinglePackage",
344 "WALK_LEVELS",
345 "discover",
346 "walk_for_package",
347]
DiscoveryResult _workspace_result(Path workspace_root, str|None package)
Definition discovery.py:238
str _package_id_for(Path root, Path config_file)
Definition discovery.py:305
DiscoveryResult discover(Path cwd, *, bool workspace=False, str|None package=None)
Definition discovery.py:157
SinglePackage walk_for_package(Path cwd)
Definition discovery.py:267