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.
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).
39 L2: lifecycle (load start / end)
40 L3: semantic details (per-package parse results)
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.
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)
67from __future__
import annotations
70from dataclasses
import dataclass, field
71from pathlib
import Path
73from oct.core.diagnostics
import _dbg
74from oct.core.workspace
import (
82 MANIFEST_TEST_BLOCK_INVALID,
84 WORKSPACE_FILE_NOT_FOUND,
85 WORKSPACE_MANIFEST_INVALID,
90TEST_PROFILES: tuple[str, ...] = (
"strict",
"smoke",
"full")
94DEFAULT_TEST_PROFILE: str =
"strict"
99DEFAULT_MAX_WORKERS: int = 24
102@dataclass(frozen=True)
104 """The §9 test configuration of one workspace subproject.
109 Subproject name (manifest ``subprojects[].name``).
111 Absolute subproject root, resolved by the base loader.
113 True only when the entry carries ``test.enabled: true``.
115 Effective TEST profile: the block's ``profile`` when set,
116 else the manifest's ``default_test_profile``, else
119 Declared subproject-root-relative test roots, in manifest
120 order - authoritative in workspace mode (ADR-T-18).
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`.
131 test_roots: tuple[str, ...] = field(default_factory=tuple)
132 max_workers: int = DEFAULT_MAX_WORKERS
135@dataclass(frozen=True)
137 """The §9 test view over a loaded workspace manifest.
142 The base :class:`~oct.core.workspace.Workspace` exactly as the
143 v0.25 loader produced it.
145 Top-level ``default_test_profile``, defaulted to ``strict``.
147 One :class:`PackageTestConfig` per subproject, manifest order
148 - including packages with no ``test`` block (disabled).
156 default_test_profile: str = DEFAULT_TEST_PROFILE
157 packages: tuple[PackageTestConfig, ...] = field(default_factory=tuple)
161 """The test-enabled packages, manifest order (§9 selection)."""
162 return tuple(pkg
for pkg
in self.
packages if pkg.enabled)
166 """Name → package lookup (regenerated per call; N is small)."""
167 return {pkg.name: pkg
for pkg
in self.
packages}
171 """Load the workspace manifest at *root* with its §9 test view.
176 Workspace root (the directory containing
177 ``.option_c_workspace.json``).
181 :class:`TestManifest`
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.
191 func =
"load_test_manifest"
192 _dbg(
"OCT-TESTING", func, f
"root={root}", 2)
194 manifest_path = Path(root) / WORKSPACE_MANIFEST
195 if not manifest_path.is_file():
197 WORKSPACE_FILE_NOT_FOUND,
198 f
"no {WORKSPACE_MANIFEST} found at {manifest_path}",
202 workspace = load_workspace(root)
203 except WorkspaceError
as exc:
205 WORKSPACE_MANIFEST_INVALID,
206 f
"workspace manifest rejected by the base loader: {exc}",
212 data = json.loads(manifest_path.read_text(encoding=
"utf-8"))
213 except (OSError, json.JSONDecodeError)
as exc:
215 WORKSPACE_MANIFEST_INVALID,
216 f
"failed to re-read workspace manifest {manifest_path}: "
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}",
232 default_test_profile=default_profile,
233 packages=tuple(packages),
238 """Validate the top-level ``default_test_profile`` key."""
239 raw = data.get(
"default_test_profile")
241 return DEFAULT_TEST_PROFILE
242 if not isinstance(raw, str)
or raw
not in TEST_PROFILES:
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}",
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):
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)",
269 packages: list[PackageTestConfig] = []
270 for idx, (entry, subproject)
in enumerate(
271 zip(entries, workspace.subprojects),
273 block = entry.get(
"test")
276 name=subproject.name,
277 path=subproject.path,
279 profile=default_profile,
284 idx, subproject, block, default_profile,
286 _dbg(
"OCT-TESTING", func, f
"parsed {len(packages)} package(s)", 4)
292 subproject: Subproject,
294 default_profile: str,
295) -> PackageTestConfig:
296 """Validate one ``subprojects[idx].test`` block (§9)."""
297 where = f
"subprojects[{idx}] ({subproject.name}).test"
299 if not isinstance(block, dict):
301 MANIFEST_TEST_BLOCK_INVALID,
302 f
"{where} must be an object, got {type(block).__name__}",
305 enabled = block.get(
"enabled")
306 if not isinstance(enabled, bool):
308 MANIFEST_TEST_BLOCK_INVALID,
309 f
"{where}.enabled must be present and a boolean, got "
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
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}",
329 name=subproject.name,
330 path=subproject.path,
333 test_roots=test_roots,
339 """Validate ``max_workers`` (Plan 239 P1): a positive int.
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.
347 raw = block.get(
"max_workers")
349 return DEFAULT_MAX_WORKERS
350 if isinstance(raw, bool)
or not isinstance(raw, int)
or raw < 1:
352 MANIFEST_TEST_BLOCK_INVALID,
353 f
"{where}.max_workers must be a positive integer or "
354 f
"omitted, got {raw!r}",
361 subproject: Subproject,
365 """Validate ``test_roots``: relative, inside the subproject root.
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.
371 roots_raw = block.get(
"test_roots")
372 if roots_raw
is None:
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)",
382 if not isinstance(roots_raw, list):
384 MANIFEST_TEST_BLOCK_INVALID,
385 f
"{where}.test_roots must be a list of strings, got "
386 f
"{type(roots_raw).__name__}",
388 if enabled
and not roots_raw:
390 MANIFEST_TEST_BLOCK_INVALID,
391 f
"{where}.test_roots must be non-empty when enabled is "
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():
400 MANIFEST_TEST_BLOCK_INVALID,
401 f
"{slot} must be a non-empty string, got {entry!r}",
403 if Path(entry).is_absolute():
405 MANIFEST_TEST_BLOCK_INVALID,
406 f
"{slot} must be relative to the subproject root, "
407 f
"got absolute path {entry!r}",
409 resolved = (subproject.path / entry).resolve()
411 resolved.relative_to(subproject.path)
414 MANIFEST_TEST_BLOCK_INVALID,
415 f
"{slot} {entry!r} escapes the subproject root "
416 f
"{subproject.path}",
423 "DEFAULT_MAX_WORKERS",
424 "DEFAULT_TEST_PROFILE",
428 "load_test_manifest",
dict[str, PackageTestConfig] packages_by_name(self)
tuple[PackageTestConfig,...] enabled_packages(self)
list[PackageTestConfig] _parse_packages(dict data, Workspace workspace, str default_profile)
int _parse_max_workers(str where, dict block)
tuple[str,...] _parse_test_roots(str where, Subproject subproject, dict block, bool enabled)
str _parse_default_profile(dict data)
TestManifest load_test_manifest(Path root)
PackageTestConfig _parse_block(int idx, Subproject subproject, object block, str default_profile)