Loading files
# Copyright (c) 2026 TiaC Systems
# SPDX-License-Identifier: Apache-2.0
"""Generic harness plumbing for the rigc integration tests --
path discovery (self-locating -- no workspace-name literals), the
rigc subprocess runner, normalization, and the freeze/assert
primitives.
This module carries nothing corpus-tethered: no boards/rigs/,
boards/shields/, or boards/extend/ content, and no knowledge of any one
rig or shield. tests/integration/'s own test modules import it directly
(`from harness import ...`); tests/integration_stay/'s corpus.py imports
it too, and layers the corpus table, the board catalog, and the
cached-plain-build machinery on top (see corpus.py's own docstring for
that half). conftest.py in this directory is a thin re-export of this
module, kept only because pytest wants a conftest.py in the directory --
no test module should import from it directly any more, since a sibling
conftest.py under tests/integration_stay/ would otherwise silently
cross-wire under Python's plain `from conftest import ...` idiom (both
directories carry no __init__.py, so sys.modules["conftest"] is
first-wins across them).
"""
from __future__ import annotations
import difflib
import logging
import os
import re
import shlex
import subprocess
import sys
from pathlib import Path
import pytest
_LOGGER = logging.getLogger(__name__)
# This file lives in tests/integration/, alongside
# the frozen suite's other own modules; TESTS_DIR is tests/ itself, one
# level up, where fixtures/ and goldens/ actually sit (siblings of
# integration/, not children of it -- fixtures/ in particular must land at
# exactly this depth for diag.anchor_path()'s "scripts/<module>/"-relative
# rendering to reproduce every frozen anchor line byte-for-byte).
TESTS_DIR = Path(__file__).resolve().parent.parent
REPO_ROOT = TESTS_DIR.parents[2] # scripts/rigc/tests -> the repo root
GOLDENS_DIR = TESTS_DIR / "goldens"
FIXTURES_DIR = TESTS_DIR / "fixtures"
def assert_fixture_local(paths: list[Path | str]) -> None:
"""Structural proof of hermeticity for a test that claims to need no
real Zephyr tree and no repo-production devicetree content: every
path it hands to board/edtlib as a --board-dts/--bindings-dir/
--include-dir resolves under FIXTURES_DIR, never under $ZEPHYR_BASE
or REPO_ROOT/dts or REPO_ROOT/include.
$ZEPHYR_BASE may still be SET for such a test (it locates the
devicetree package itself, which this workspace's zephyr branch
patches -- board.edt_build.ensure_devicetree_on_path -- so it is not
something a test can or should route around); what this asserts is
that none of its DATA leaks in. Checking the caller's own recipe
inputs (rather than, say, the ABSENCE of $ZEPHYR_BASE) is what makes
"hermetic" a property of what the test actually reads, not an
accident of how it was invoked."""
for p in paths:
resolved = Path(p).resolve()
assert str(resolved) == str(FIXTURES_DIR) or str(resolved).startswith(
str(FIXTURES_DIR) + os.sep
), (
f"{resolved} is outside {FIXTURES_DIR} -- a test asserting "
"hermeticity must reference only its own fixture-tree paths"
)
def _find_west_topdir(start: Path) -> Path:
"""Walk upward from start to the west workspace root (the directory
holding .west/) — self-locating, no hardcoded workspace-name literal."""
for candidate in (start, *start.parents):
if (candidate / ".west").is_dir():
return candidate
raise RuntimeError(f"no .west/ found above {start} — is this a west workspace?")
WEST_TOPDIR = _find_west_topdir(REPO_ROOT)
_VENV_WEST = WEST_TOPDIR / ".venv" / "bin" / "west"
WEST_EXE = str(_VENV_WEST) if _VENV_WEST.is_file() else "west"
# RIGC_REFREEZE=1 rewrites goldens instead of asserting against them (both
# emitted and resolved). Always inspect git diff tests/goldens after a
# refreeze — it must reflect an INTENTIONAL, understood behavior change,
# never silent drift.
REFREEZE = bool(os.environ.get("RIGC_REFREEZE"))
# RIGC_COMPILE: the module knob -- the
# Python module name of the CLI under test, read ONCE here from the
# environment (same name as cmake/modules/dts.cmake's own cache variable of the same
# name, deliberately: most subprocesses this suite launches inherit this
# process's environment wholesale, e.g. via env=dict(os.environ), so that
# cache variable's own environment fallback picks up the SAME value without
# every call site needing to thread an explicit -D). rigc is the only
# module this name may resolve to today.
RIGC_COMPILE = os.environ.get("RIGC_COMPILE", "rigc")
def subprocess_timeout(default: int) -> int | None:
"""The default for every long-running subprocess.run() timeout across
the integration tests, overridable via RIGC_SUBPROCESS_TIMEOUT (seconds;
0 disables the timeout). subprocess.run's timeout clock runs in the
pytest process and is oblivious to a debugger paused inside the child --
past the timeout it kills that child regardless, ending the debug
session out from under you. Set RIGC_SUBPROCESS_TIMEOUT=0 (e.g. via a
project-local .env picked up by nvim-dap-python) while debugging into a
subprocess.run child. Not applied to the short dts_equiv.py comparisons,
which carry no timeout of their own."""
raw = os.environ.get("RIGC_SUBPROCESS_TIMEOUT")
if not raw:
return default
value = int(raw)
return value if value > 0 else None
# rigc's workdir is <--out-dir>/rigc-generated (cli.WORKDIR_NAME), so
# the leading part varies per run -- a pytest tmp_path here, a real build
# directory under cmake. Match the whole absolute path up to and including
# the fixed trailing component: anchoring on "rigc-generated" alone would
# leave the run-specific prefix in the text, and matching a bare
# "/generated" would collide with zephyr's own include/generated.
_WORKDIR_RE = re.compile(r"/[^\s]*rigc-generated")
# A resolved zephyr.dts's own DT provenance comments (/* in PATH:LINE */,
# /* node 'X' defined in PATH:LINE */) render PATH relative to the build's
# cwd (WEST_TOPDIR) — e.g. ../../../tmp/pytest-of-<user>/pytest-52/
# test_resolved_accept_zephyr_dt0/build/rig/rig-gen.overlay:25 — which embeds
# pytest's OWN per-session tmp dir (tmp_path, a fresh directory every test
# run: test_resolved_corpus._run_build builds into tmp_path / "build").
# Byte-freezing that raw text would make every refreeze session rewrite
# every resolved golden on this fragment alone, with no content change at
# all. (?:\.\./)+ (not a fixed count) tolerates whatever depth WEST_TOPDIR
# sits at under the filesystem root on a given machine.
_DTS_BUILD_PROVENANCE_RE = re.compile(
r"(?:\.\./)+tmp/pytest-of-[^/\s]+/pytest-\d+/[^/\s]+/build/(rig/[^:\s*]+):(\d+)"
)
def normalize_dts_provenance(text: str) -> str:
"""Replace a resolved zephyr.dts's pytest-tmp-dir-dependent provenance
comment paths with a stable placeholder, keeping the meaningful
generated-file-relative part (rig/<file>:<line>) intact — comments
only, so dts_equiv.py's structural comparison (which ignores comments)
is unaffected either way; this exists purely so a refreeze's diff shows
real content changes, not tmp-path churn."""
return _DTS_BUILD_PROVENANCE_RE.sub(r"<RIGC_BUILD>/\1:\2", text)
def zephyr_base() -> str:
"""The zephyr tree rigc / dts_equiv.py need, from $ZEPHYR_BASE."""
value = os.environ.get("ZEPHYR_BASE")
if not value:
pytest.fail(
"ZEPHYR_BASE is not set — export it (the zephyr-rigs tree), the "
"same way scripts/check.sh requires."
)
return value
def normalize(text: str, zb: str | None) -> str:
"""Replace machine-/run-specific absolute paths with stable placeholders
before freezing/comparing: rigc's own temp workdir,
$ZEPHYR_BASE, and the repo root (in that order — repo root and zephyr
base can each be a prefix of the other under a shared workspace topdir, so
the more specific substitutions must land first). This function only
replaces machine-specific paths; WHETHER what is left compares
byte-exact or by contract is freeze_or_assert's decision, per artifact.
zb is None for a golden-comparing test that needs no real Zephyr tree at
all (a hermetic fixture) -- that one substitution is skipped rather than
forcing every such caller through zephyr_base()'s hard failure just to
normalize output that never contained a $ZEPHYR_BASE path to begin
with. Every other substitution still applies unconditionally."""
text = _WORKDIR_RE.sub("<RIGC_WORKDIR>", text)
if zb is not None:
text = text.replace(zb, "<ZEPHYR_BASE>")
text = text.replace(str(REPO_ROOT), "<REPO_ROOT>")
text = text.replace(str(WEST_TOPDIR), "<WEST_TOPDIR>")
return text
def render_argv(result: subprocess.CompletedProcess[str]) -> str:
"""Shell-quoted rendering of a completed subprocess's own argv, for a
failure assertion to interpolate alongside stdout/stderr -- .args is
exactly what subprocess.run was given, so this needs no extra plumbing
at any call site: -s cannot show a captured subprocess's command (only
the test process's own stdout), and no assertion in this suite named
the command that produced a failure until this existed."""
return shlex.join(str(part) for part in result.args)
def write_rerun_script(script_dir: Path, cwd: Path, cmd: list[str], env: dict[str, str]) -> Path:
"""Write an executable rerun.sh into script_dir: a standalone re-run of
this exact subprocess invocation, mirroring cmake/modules/dts.cmake's own
rerun-expand.sh (shebang, set -e, the env-then-argv shape) -- written
BEFORE the subprocess runs, so it survives even a failing invocation,
exactly like the cmake precedent keeps its script after a FAILED
configure. Composes with pytest's tmp_path_retention_policy (default
failed): a failing test's tmp dir, and therefore this script, is kept
without any extra flag; -o tmp_path_retention_policy=all keeps a
passing one's too.
cwd is recorded as an explicit cd line rather than left to whatever
directory the script happens to be run from: a diagnostic that renders
a process-cwd-relative path would otherwise reproduce DIFFERENTLY than
the original failure, defeating the point of a reproduction script.
Only the env entries this invocation's caller added on top of the
inherited environment are exported -- recording every inherited
variable would bury the ones that actually distinguish this
invocation, and would embed values (e.g. a caller's current PATH) that
have nothing to do with reproducing it."""
lines = [
"#!/bin/sh",
"# regenerate: rewritten on every test run -- edits here do not persist.",
"# Standalone re-run of this test's own subprocess invocation, e.g. under",
f"# a debugger: copy the env + argv below into 'python3 -m pdb -m {RIGC_COMPILE} ...'.",
"set -e",
f"cd {shlex.quote(str(cwd))}",
]
for key, value in env.items():
if os.environ.get(key) != value:
lines.append(f"export {key}={shlex.quote(value)}")
lines.append("exec " + " ".join(shlex.quote(str(c)) for c in cmd) + ' "$@"')
script_dir.mkdir(parents=True, exist_ok=True)
script = script_dir / "rerun.sh"
script.write_text("\n".join(lines) + "\n")
script.chmod(0o755)
return script
# The artifact filenames the emitter may produce, shared by
# test_emitted_rejects.py and test_emitted_corpus.py. Order is stable so a
# refreeze's git diff stays readable. rig-gen-includes.dtsi is emitted only
# when a rig's own parameter assignments need a header at all (today, only
# lotus_buttons -- emitter._needed_param_includes) -- assert_absent_or_
# refreeze covers the "correctly absent" case for every other corpus rig,
# the same way it already does for rig-gen.conf.
EMITTED_FILES = (
"rig-gen.overlay",
"rig-gen-includes.dtsi",
"context.cmake",
"config-sheet.md",
"rig-gen.conf",
)
def freeze_or_assert(golden_path: Path, content: str) -> None:
"""Write content as the golden (RIGC_REFREEZE=1) or assert it matches
the committed fixture, with a readable failure message on mismatch.
context.cmake, config-sheet.md, and rig-gen-includes.dtsi
(golden_path.name, not a directory check -- this is the single seam
every EMITTED_FILES artifact passes through) compare STRUCTURALLY:
context.cmake as a key -> value mapping, with RIG_DEPENDS as a set;
config-sheet.md as the facts it carries (instance/socket/address/
index/... -- see compare.py), never its prose rendering;
rig-gen-includes.dtsi as the ORDERED header list the owning shield
devices' own shield,param-includes declared. rig-gen.overlay compares
through compare_overlay (targeted
assertions only -- its semantics ride the zephyr.dts + dts_equiv.py
comparison instead) EXCEPT for golden_path.parent.name (the rig's own
golden directory) satisfying overlay_is_byte_compared -- the one rig
with no zephyr.dts, which stays byte-compared so it keeps SOME check
on this artifact.
What remains byte-compared is exit_code and stderr.txt, and those two
stay that way PERMANENTLY -- not pending a comparator. The reject
corpus's rendered diagnostic wording is a user-facing product surface
(a rig author reads it), which is the whole reason those goldens
exist; loosening that comparison is out of scope."""
if REFREEZE:
golden_path.parent.mkdir(parents=True, exist_ok=True)
golden_path.write_text(content)
return
if not golden_path.is_file():
pytest.fail(
f"golden missing: {golden_path}\n"
f"(run with RIGC_REFREEZE=1 to create it, then inspect + "
f"commit deliberately)"
)
expected = golden_path.read_text()
if golden_path.name == "context.cmake":
mismatch = compare_context_cmake(expected, content)
if mismatch is not None:
pytest.fail(f"golden mismatch: {golden_path}\n{mismatch}")
return
if golden_path.name == "config-sheet.md":
mismatch = compare_config_sheet(expected, content)
if mismatch is not None:
pytest.fail(f"golden mismatch: {golden_path}\n{mismatch}")
return
if golden_path.name == "rig-gen-includes.dtsi":
mismatch = compare_includes_dtsi(expected, content)
if mismatch is not None:
pytest.fail(f"golden mismatch: {golden_path}\n{mismatch}")
return
if golden_path.name == "rig-gen.overlay" and not overlay_is_byte_compared(
golden_path.parent.name
):
mismatch = compare_overlay(expected, content)
if mismatch is not None:
pytest.fail(f"golden mismatch: {golden_path}\n{mismatch}")
return
if expected != content:
diff = "\n".join(
difflib.unified_diff(
expected.splitlines(),
content.splitlines(),
fromfile=str(golden_path),
tofile="<observed>",
lineterm="",
)
)
pytest.fail(f"golden mismatch: {golden_path}\n{diff}")
def assert_absent_or_refreeze(golden_path: Path) -> None:
"""The counterpart of freeze_or_assert for an artifact the current run did
NOT produce: under refreeze, drop a now-stale golden; otherwise assert
none is committed — a golden for a file rigc no longer emits is
itself a drift worth catching, not something to pass silently."""
if REFREEZE:
if golden_path.is_file():
golden_path.unlink()
return
assert not golden_path.is_file(), (
f"golden {golden_path} exists but this run produced no such file"
)
# This directory carries no __init__.py (the frozen suite's own modules
# import each other as plain top-level names, e.g. "from conftest import
# ..."), so it is never part of the rigc package chain pytest walks to put
# scripts/ on sys.path by itself -- every integration module that needs an
# in-process rigc import inserts scripts/ explicitly (test_board_read.py,
# test_reference_shields.py, etc.); this is that same idiom, for the
# comparators context.cmake and config-sheet.md need structurally rather
# than byte-for-byte.
sys.path.insert(0, str(REPO_ROOT / "scripts"))
from rigc.tests.compare import ( # noqa: E402
compare_config_sheet,
compare_context_cmake,
compare_includes_dtsi,
compare_overlay,
overlay_is_byte_compared,
)
def run_expand(
rig_yml: Path,
out_dir: Path,
shield_dirs: list[Path] | None = None,
board: str | None = None,
board_dts: Path | None = None,
build_info: Path | None = None,
bindings_dirs: list[Path] | None = None,
include_dirs: list[Path] | None = None,
revision: str | None = None,
variant: str | None = None,
connector_dirs: list[Path] | None = None,
) -> subprocess.CompletedProcess[str]:
"""Run python -m <RIGC_COMPILE> expand exactly as dts.cmake does
(modulo the recipe form: dts.cmake passes --include-dir/--bindings-dir
explicitly;
this harness reuses a cached plain build's --build-info instead, per the
cached-plain-build pattern — see plain_build_for) — a real subprocess,
cwd pinned to the repo root so any process-cwd-relative path a
diagnostic renders is reproducible regardless of the caller's cwd.
shield_dirs is explicit here, with no repo-shield default: this
harness copy carries none of boards/shields/'s own tether, so a
caller in this directory (or any other hermetic caller) must pass its
own --shield-dir list, or none at all. tests/integration_stay/
corpus.py's own run_expand wrapper restores the old
[SHIELD_DIR] default for its corpus-tethered callers.
include_dirs is the cpp -I side of the explicit recipe (cli.py
--include-dir); a hermetic fixture board whose .dts #includes its own
fixture-local header needs this alongside bindings_dirs -- board_dts/
build_info were the only two forms the harness needed until a fixture
board required an #include of its own, so this was never plumbed
through until now.
board_dts/build_info are both None for the unknown-board fixture —
deliberately, so the CLI exercises board.resolve's own name->dts DISCOVERY
(list_boards.py) and its "board not found" diagnostic, exactly as a bare
standalone invocation would.
board threads cli.py's own --board: the board the INVOCATION supplies,
winning over whatever the rig declares (nothing -- no corpus rig.yml
declares a board) unconditionally. Omitted (None) means no injection
at all -- the rig must declare its own board, or the loader rejects
it exactly as an ordinary `rigc expand` with no -DBOARD would. Every
corpus rig call site passes this now (RigCase.board); a caller
wanting the un-injected diagnostic path itself (no-board-declared)
leaves it unset on purpose.
revision/variant carry the SELECTED qualifier axis values -- the
harness's stand-in for what cmake/modules/dts.cmake's fork would resolve via
list_rigs.py before invoking this same CLI. Omitted (None) means a
bare target: the loader applies the rig's own declared default, if
any.
connector_dirs is cli.py's --connector-dir (repeatable): a fixture rig
that must MATE a shield against a synthetic connector type needs this,
since ctypes_registry's default is the real dts/bindings/connectors
directory alone. Each type's header still resolves through
include_dirs, not a separate list — see cli.py's own docstring."""
zb = zephyr_base()
env = dict(os.environ)
env["ZEPHYR_BASE"] = zb
env["PYTHONPATH"] = str(REPO_ROOT / "scripts")
dirs = shield_dirs if shield_dirs is not None else []
cmd = [sys.executable, "-m", RIGC_COMPILE, "expand", str(rig_yml)]
for d in dirs:
cmd += ["--shield-dir", str(d)]
if board is not None:
cmd += ["--board", board]
if board_dts is not None:
cmd += ["--board-dts", str(board_dts)]
if build_info is not None:
cmd += ["--build-info", str(build_info)]
for b in bindings_dirs or []:
cmd += ["--bindings-dir", str(b)]
for i in include_dirs or []:
cmd += ["--include-dir", str(i)]
for c in connector_dirs or []:
cmd += ["--connector-dir", str(c)]
if revision is not None:
cmd += ["--revision", revision]
if variant is not None:
cmd += ["--variant", variant]
cmd += ["--out-dir", str(out_dir)]
_LOGGER.info("expand argv: %s", shlex.join(cmd))
write_rerun_script(out_dir, REPO_ROOT, cmd, env)
return subprocess.run(
cmd,
env=env,
cwd=str(REPO_ROOT),
capture_output=True,
text=True,
timeout=subprocess_timeout(120),
)
efd4b04f2This file is pinned to the selected release. Download the original to open it in your design tool.