oct
Loading...
Searching...
No Matches
manifest.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/testing/manifest.py
4
5"""
6Purpose
7-------
8Parse + validate the spec-10 §9 workspace-manifest ``test`` blocks
9(Plan 189). The shipping v0.25 base loader
10(``oct.core.workspace.load_workspace``) owns the base schema and
11deliberately ignores unknown keys; this module layers the §9 test
12configuration on top by re-reading the raw manifest for the additive
13keys (``subprojects[].test``, top-level ``default_test_profile``)
14without touching the base loader.
15
16Responsibilities
17----------------
18- :func:`load_test_manifest` - load the base workspace via the v0.25
19 loader, then parse/validate the test blocks into a
20 :class:`TestManifest` of :class:`PackageTestConfig` entries.
21- Own the Plan-239 P1 ``max_workers`` key: the per-package cap on test
22 workers, validated as a positive int, defaulted to
23 :data:`DEFAULT_MAX_WORKERS`. Both the pytest side
24 (``oct.testing.parallel``) and the §20 stand-in read this one key.
25- Map every invalid-manifest condition to the §12.4 taxonomy as data
26 (:class:`oct.testing.errors.OctTestError`): a base-loader rejection
27 is the documented ``9101``; a test-config content violation is the
28 documented ``9102``; a missing manifest is
29 ``WORKSPACE_FILE_NOT_FOUND``. NO ``sys.exit`` here - exits are
30 mapped by the future CLI slice.
31- Pin the TEST profile vocabulary (``strict``/``smoke``/``full``) -
32 distinct from the LINT profiles (``proto``/``compact``/``strict``)
33 that live in ``subprojects[].profile`` (the §9 vocabulary hazard).
34
35Diagnostics
36-----------
37Domain: OCT-TESTING
38Levels:
39 L2: lifecycle (load start / end)
40 L3: semantic details (per-package parse results)
41 L4: deep tracing
42
43Contracts
44---------
45- ``oct.core.workspace`` is a READ-ONLY dependency: base-schema
46 semantics are exactly the v0.25 loader's, witnessed by
47 ``tests/tests_testing/test_loader_tolerance_witness.py``.
48- ``test_roots`` are SUBPROJECT-ROOT-RELATIVE (§9): absolute entries
49 and entries escaping the subproject root are configuration errors.
50- An ENABLED test block must declare non-empty ``test_roots`` -
51 in workspace mode the manifest roots are authoritative (ADR-T-18),
52 so an enabled package without roots is a misdeclaration.
53- Existence of declared roots is NOT checked here: stale/empty roots
54 are collection-time findings (§10 / §12.4 ``EMPTY_TEST_ROOT`` /
55 ``UNDECLARED_TEST_ROOT``), owned by the future collection slice.
56- ``max_workers`` refuses rather than degrades: a non-positive or
57 non-integer value raises ``9102``, because a silently-defaulted cap
58 is exactly the kind of configuration drift a run cannot detect.
59
60Dependencies
61------------
62- oct.core.workspace (read-only: base schema + resolved paths)
63- oct.core.diagnostics (_dbg)
64- oct.testing.errors (the §12.4 taxonomy as data)
65"""
66
67from __future__ import annotations
68
69import json
70from dataclasses import dataclass, field
71from pathlib import Path
72
73from oct.core.diagnostics import _dbg
74from oct.core.workspace import (
75 WORKSPACE_MANIFEST,
76 Subproject,
77 Workspace,
78 WorkspaceError,
79 load_workspace,
80)
81from oct.testing.errors import (
82 MANIFEST_TEST_BLOCK_INVALID,
83 OctTestError,
84 WORKSPACE_FILE_NOT_FOUND,
85 WORKSPACE_MANIFEST_INVALID,
86)
87
88
89#: The spec-10 §5 TEST profiles (NOT the lint profiles).
90TEST_PROFILES: tuple[str, ...] = ("strict", "smoke", "full")
91
92#: §9 per-package config table: profile default is the workspace
93#: default, else ``strict``.
94DEFAULT_TEST_PROFILE: str = "strict"
95
96#: Plan 239 P1: the shipped cap on test workers when a package's test
97#: block declares no ``max_workers`` (operator ruling 2026-09-06).
98#: ``auto`` resolves to ``min(max_workers, os.cpu_count())``.
99DEFAULT_MAX_WORKERS: int = 24
100
101
102@dataclass(frozen=True)
104 """The §9 test configuration of one workspace subproject.
105
106 Attributes
107 ----------
108 name
109 Subproject name (manifest ``subprojects[].name``).
110 path
111 Absolute subproject root, resolved by the base loader.
112 enabled
113 True only when the entry carries ``test.enabled: true``.
114 profile
115 Effective TEST profile: the block's ``profile`` when set,
116 else the manifest's ``default_test_profile``, else
117 ``"strict"``.
118 test_roots
119 Declared subproject-root-relative test roots, in manifest
120 order - authoritative in workspace mode (ADR-T-18).
121 max_workers
122 Plan 239 P1 cap on parallel test workers for this package:
123 the block's ``max_workers`` when set, else
124 :data:`DEFAULT_MAX_WORKERS`.
125 """
126
127 name: str
128 path: Path
129 enabled: bool
130 profile: str
131 test_roots: tuple[str, ...] = field(default_factory=tuple)
132 max_workers: int = DEFAULT_MAX_WORKERS
133
134
135@dataclass(frozen=True)
137 """The §9 test view over a loaded workspace manifest.
138
139 Attributes
140 ----------
141 workspace
142 The base :class:`~oct.core.workspace.Workspace` exactly as the
143 v0.25 loader produced it.
144 default_test_profile
145 Top-level ``default_test_profile``, defaulted to ``strict``.
146 packages
147 One :class:`PackageTestConfig` per subproject, manifest order
148 - including packages with no ``test`` block (disabled).
149 """
150
151 # Not a pytest collectable despite the Test* name (no annotation,
152 # so the dataclass machinery ignores it).
153 __test__ = False
154
155 workspace: Workspace
156 default_test_profile: str = DEFAULT_TEST_PROFILE
157 packages: tuple[PackageTestConfig, ...] = field(default_factory=tuple)
158
159 @property
160 def enabled_packages(self) -> tuple[PackageTestConfig, ...]:
161 """The test-enabled packages, manifest order (§9 selection)."""
162 return tuple(pkg for pkg in self.packages if pkg.enabled)
163
164 @property
165 def packages_by_name(self) -> dict[str, PackageTestConfig]:
166 """Name → package lookup (regenerated per call; N is small)."""
167 return {pkg.name: pkg for pkg in self.packages}
168
169
170def load_test_manifest(root: Path) -> TestManifest:
171 """Load the workspace manifest at *root* with its §9 test view.
172
173 Parameters
174 ----------
175 root
176 Workspace root (the directory containing
177 ``.option_c_workspace.json``).
178
179 Returns
180 -------
181 :class:`TestManifest`
182
183 Raises
184 ------
185 OctTestError
186 ``WORKSPACE_FILE_NOT_FOUND`` when no manifest exists at
187 *root*; ``9101`` when the manifest exists but the v0.25 base
188 loader rejects it; ``9102`` when the base schema is valid but
189 the §9 test configuration is not.
190 """
191 func = "load_test_manifest"
192 _dbg("OCT-TESTING", func, f"root={root}", 2)
193
194 manifest_path = Path(root) / WORKSPACE_MANIFEST
195 if not manifest_path.is_file():
196 raise OctTestError(
197 WORKSPACE_FILE_NOT_FOUND,
198 f"no {WORKSPACE_MANIFEST} found at {manifest_path}",
199 )
200
201 try:
202 workspace = load_workspace(root)
203 except WorkspaceError as exc:
204 raise OctTestError(
205 WORKSPACE_MANIFEST_INVALID,
206 f"workspace manifest rejected by the base loader: {exc}",
207 ) from exc
208
209 # Re-read the raw manifest for the additive keys the base loader
210 # deliberately ignores (its Workspace carries no unknown keys).
211 try:
212 data = json.loads(manifest_path.read_text(encoding="utf-8"))
213 except (OSError, json.JSONDecodeError) as exc:
214 raise OctTestError(
215 WORKSPACE_MANIFEST_INVALID,
216 f"failed to re-read workspace manifest {manifest_path}: "
217 f"{exc}",
218 ) from exc
219
220 default_profile = _parse_default_profile(data)
221 packages = _parse_packages(data, workspace, default_profile)
222
223 _dbg(
224 "OCT-TESTING", func,
225 f"loaded: {len(packages)} package(s), "
226 f"{sum(1 for p in packages if p.enabled)} test-enabled, "
227 f"default profile {default_profile!r}",
228 3,
229 )
230 return TestManifest(
231 workspace=workspace,
232 default_test_profile=default_profile,
233 packages=tuple(packages),
234 )
235
236
237def _parse_default_profile(data: dict) -> str:
238 """Validate the top-level ``default_test_profile`` key."""
239 raw = data.get("default_test_profile")
240 if raw is None:
241 return DEFAULT_TEST_PROFILE
242 if not isinstance(raw, str) or raw not in TEST_PROFILES:
243 raise OctTestError(
244 MANIFEST_TEST_BLOCK_INVALID,
245 f"'default_test_profile' must be one of "
246 f"{'/'.join(TEST_PROFILES)} (TEST profiles, not lint "
247 f"profiles) or omitted, got {raw!r}",
248 )
249 return raw
250
251
253 data: dict,
254 workspace: Workspace,
255 default_profile: str,
256) -> list[PackageTestConfig]:
257 """Build one :class:`PackageTestConfig` per subproject entry."""
258 func = "_parse_packages"
259 entries = data.get("subprojects", [])
260 if len(entries) != len(workspace.subprojects):
261 # The manifest changed between the base load and the re-read.
262 raise OctTestError(
263 WORKSPACE_MANIFEST_INVALID,
264 "workspace manifest changed while it was being loaded "
265 f"({len(workspace.subprojects)} subprojects at base load, "
266 f"{len(entries)} at test-block read)",
267 )
268
269 packages: list[PackageTestConfig] = []
270 for idx, (entry, subproject) in enumerate(
271 zip(entries, workspace.subprojects),
272 ):
273 block = entry.get("test")
274 if block is None:
275 packages.append(PackageTestConfig(
276 name=subproject.name,
277 path=subproject.path,
278 enabled=False,
279 profile=default_profile,
280 test_roots=(),
281 ))
282 continue
283 packages.append(_parse_block(
284 idx, subproject, block, default_profile,
285 ))
286 _dbg("OCT-TESTING", func, f"parsed {len(packages)} package(s)", 4)
287 return packages
288
289
291 idx: int,
292 subproject: Subproject,
293 block: object,
294 default_profile: str,
295) -> PackageTestConfig:
296 """Validate one ``subprojects[idx].test`` block (§9)."""
297 where = f"subprojects[{idx}] ({subproject.name}).test"
298
299 if not isinstance(block, dict):
300 raise OctTestError(
301 MANIFEST_TEST_BLOCK_INVALID,
302 f"{where} must be an object, got {type(block).__name__}",
303 )
304
305 enabled = block.get("enabled")
306 if not isinstance(enabled, bool):
307 raise OctTestError(
308 MANIFEST_TEST_BLOCK_INVALID,
309 f"{where}.enabled must be present and a boolean, got "
310 f"{enabled!r}",
311 )
312
313 profile_raw = block.get("profile")
314 if profile_raw is None:
315 profile = default_profile
316 elif isinstance(profile_raw, str) and profile_raw in TEST_PROFILES:
317 profile = profile_raw
318 else:
319 raise OctTestError(
320 MANIFEST_TEST_BLOCK_INVALID,
321 f"{where}.profile must be one of "
322 f"{'/'.join(TEST_PROFILES)} (TEST profiles, not lint "
323 f"profiles) or omitted, got {profile_raw!r}",
324 )
325
326 test_roots = _parse_test_roots(where, subproject, block, enabled)
327
328 return PackageTestConfig(
329 name=subproject.name,
330 path=subproject.path,
331 enabled=enabled,
332 profile=profile,
333 test_roots=test_roots,
334 max_workers=_parse_max_workers(where, block),
335 )
336
337
338def _parse_max_workers(where: str, block: dict) -> int:
339 """Validate ``max_workers`` (Plan 239 P1): a positive int.
340
341 An absent key takes :data:`DEFAULT_MAX_WORKERS`. A non-positive
342 or non-integer value is a configuration error, not a silent
343 degrade: a cap of 0 or -1 would otherwise read as "no limit" at
344 one call site and "serial" at another. ``True``/``False`` are
345 ints in Python and are refused explicitly.
346 """
347 raw = block.get("max_workers")
348 if raw is None:
349 return DEFAULT_MAX_WORKERS
350 if isinstance(raw, bool) or not isinstance(raw, int) or raw < 1:
351 raise OctTestError(
352 MANIFEST_TEST_BLOCK_INVALID,
353 f"{where}.max_workers must be a positive integer or "
354 f"omitted, got {raw!r}",
355 )
356 return raw
357
358
360 where: str,
361 subproject: Subproject,
362 block: dict,
363 enabled: bool,
364) -> tuple[str, ...]:
365 """Validate ``test_roots``: relative, inside the subproject root.
366
367 An ENABLED block must declare at least one root (ADR-T-18 makes
368 the declared roots authoritative in workspace mode); a disabled
369 block may omit the key entirely.
370 """
371 roots_raw = block.get("test_roots")
372 if roots_raw is None:
373 if enabled:
374 raise OctTestError(
375 MANIFEST_TEST_BLOCK_INVALID,
376 f"{where}.test_roots must be a non-empty list when "
377 f"enabled is true (declared roots are authoritative "
378 f"in workspace mode)",
379 )
380 return ()
381
382 if not isinstance(roots_raw, list):
383 raise OctTestError(
384 MANIFEST_TEST_BLOCK_INVALID,
385 f"{where}.test_roots must be a list of strings, got "
386 f"{type(roots_raw).__name__}",
387 )
388 if enabled and not roots_raw:
389 raise OctTestError(
390 MANIFEST_TEST_BLOCK_INVALID,
391 f"{where}.test_roots must be non-empty when enabled is "
392 f"true",
393 )
394
395 roots: list[str] = []
396 for ridx, entry in enumerate(roots_raw):
397 slot = f"{where}.test_roots[{ridx}]"
398 if not isinstance(entry, str) or not entry.strip():
399 raise OctTestError(
400 MANIFEST_TEST_BLOCK_INVALID,
401 f"{slot} must be a non-empty string, got {entry!r}",
402 )
403 if Path(entry).is_absolute():
404 raise OctTestError(
405 MANIFEST_TEST_BLOCK_INVALID,
406 f"{slot} must be relative to the subproject root, "
407 f"got absolute path {entry!r}",
408 )
409 resolved = (subproject.path / entry).resolve()
410 try:
411 resolved.relative_to(subproject.path)
412 except ValueError:
413 raise OctTestError(
414 MANIFEST_TEST_BLOCK_INVALID,
415 f"{slot} {entry!r} escapes the subproject root "
416 f"{subproject.path}",
417 ) from None
418 roots.append(entry)
419 return tuple(roots)
420
421
422__all__ = [
423 "DEFAULT_MAX_WORKERS",
424 "DEFAULT_TEST_PROFILE",
425 "PackageTestConfig",
426 "TEST_PROFILES",
427 "TestManifest",
428 "load_test_manifest",
429]
dict[str, PackageTestConfig] packages_by_name(self)
Definition manifest.py:165
tuple[PackageTestConfig,...] enabled_packages(self)
Definition manifest.py:160
list[PackageTestConfig] _parse_packages(dict data, Workspace workspace, str default_profile)
Definition manifest.py:256
int _parse_max_workers(str where, dict block)
Definition manifest.py:338
tuple[str,...] _parse_test_roots(str where, Subproject subproject, dict block, bool enabled)
Definition manifest.py:364
str _parse_default_profile(dict data)
Definition manifest.py:237
TestManifest load_test_manifest(Path root)
Definition manifest.py:170
PackageTestConfig _parse_block(int idx, Subproject subproject, object block, str default_profile)
Definition manifest.py:295