hector-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hector/__init__.py +4 -0
- hector/__main__.py +7 -0
- hector/commands/__init__.py +35 -0
- hector/commands/base.py +111 -0
- hector/commands/export.py +26 -0
- hector/commands/init.py +15 -0
- hector/commands/run.py +27 -0
- hector/commands/test.py +52 -0
- hector/commands/validate.py +15 -0
- hector/connections.py +130 -0
- hector/core.py +259 -0
- hector/dependencies.py +41 -0
- hector/docker.py +191 -0
- hector/generator.py +292 -0
- hector/hubs.py +196 -0
- hector/mappings.py +167 -0
- hector/modules.py +201 -0
- hector/peripherals.py +168 -0
- hector/pipeline.py +693 -0
- hector/reporters.py +111 -0
- hector/runners.py +380 -0
- hector/scaffold.py +75 -0
- hector/validator.py +414 -0
- hector_cli-0.1.0.dist-info/METADATA +1401 -0
- hector_cli-0.1.0.dist-info/RECORD +29 -0
- hector_cli-0.1.0.dist-info/WHEEL +5 -0
- hector_cli-0.1.0.dist-info/entry_points.txt +2 -0
- hector_cli-0.1.0.dist-info/licenses/LICENSE +661 -0
- hector_cli-0.1.0.dist-info/top_level.txt +1 -0
hector/pipeline.py
ADDED
|
@@ -0,0 +1,693 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
"""
|
|
3
|
+
The conductor: CLI parsing and the per-job pipeline that ties the modules together.
|
|
4
|
+
|
|
5
|
+
Phases per job:
|
|
6
|
+
1 build module types (modules.py) + run the global build steps
|
|
7
|
+
1.5 instantiate peripherals, resolve mappings + connections, finalize repl/commands
|
|
8
|
+
2 generate one emulation resc; then simulate / debug / test
|
|
9
|
+
3 sweep artifacts
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import copy
|
|
13
|
+
import os
|
|
14
|
+
import shlex
|
|
15
|
+
import sys
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
|
|
18
|
+
from . import connections as conn
|
|
19
|
+
from . import core as _core
|
|
20
|
+
from . import mappings as maps
|
|
21
|
+
from . import peripherals as periph
|
|
22
|
+
from .core import (ARTIFACTS_DIR, DEFAULT_QUANTUM, SUPPORTED_SCHEMA_VERSIONS,
|
|
23
|
+
RuntimeOptions, as_lines, expand_matrix, interpolate, load_yaml,
|
|
24
|
+
resolve_arguments, to_container_path)
|
|
25
|
+
from .dependencies import framework_vars, prepare_dependencies
|
|
26
|
+
from .docker import build_renode_argv, run_renode
|
|
27
|
+
from . import generator as gen
|
|
28
|
+
from .generator import (collect_artifacts, finalize_commands, generate_emulation_resc,
|
|
29
|
+
snapshot_resc)
|
|
30
|
+
from .hubs import HUBS
|
|
31
|
+
from .modules import BuildContext, build_modules
|
|
32
|
+
from .reporters import emit
|
|
33
|
+
from .runners import RunnerContext, run_build_steps, run_tests
|
|
34
|
+
from .validator import print_issues, validate_config
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
# Shared engine
|
|
39
|
+
#
|
|
40
|
+
# The CLI surface (argparse subcommands) lives in hector.commands; each command
|
|
41
|
+
# is a small module that parses its own flags and then calls the helpers below to
|
|
42
|
+
# load the config, assemble a JobConfig, and run the matrix. Adding a command is
|
|
43
|
+
# adding one module — no changes here.
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
def apply_global_flags(args):
|
|
47
|
+
"""Honour --workspace-mount / --no-docker before anything touches paths."""
|
|
48
|
+
if getattr(args, "workspace_mount", None):
|
|
49
|
+
_core.WORKSPACE_MOUNT = args.workspace_mount
|
|
50
|
+
if getattr(args, "no_docker", False):
|
|
51
|
+
_core.NO_DOCKER = True
|
|
52
|
+
_core.WORKSPACE_MOUNT = os.getcwd()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load_config_or_exit():
|
|
56
|
+
"""Load and sanity-check ./.hector.yaml, or print an error and exit."""
|
|
57
|
+
config_path = ".hector.yaml"
|
|
58
|
+
if not os.path.exists(config_path):
|
|
59
|
+
print(f"[ERROR] No '{config_path}' in the current directory ({os.getcwd()}).\n"
|
|
60
|
+
" Run hector from a project that has a .hector.yaml, or create one "
|
|
61
|
+
"with 'hector init'.", file=sys.stderr)
|
|
62
|
+
sys.exit(1)
|
|
63
|
+
try:
|
|
64
|
+
config = load_yaml(config_path)
|
|
65
|
+
except Exception as exc:
|
|
66
|
+
print(f"[ERROR] Could not parse '{config_path}': {exc}", file=sys.stderr)
|
|
67
|
+
sys.exit(1)
|
|
68
|
+
if not isinstance(config, dict):
|
|
69
|
+
print(f"[ERROR] '{config_path}' must be a YAML mapping (got "
|
|
70
|
+
f"{type(config).__name__}).", file=sys.stderr)
|
|
71
|
+
sys.exit(1)
|
|
72
|
+
v = config.get("version")
|
|
73
|
+
if v is not None and str(v) not in SUPPORTED_SCHEMA_VERSIONS:
|
|
74
|
+
print(f"[WARN] .hector.yaml 'version: {v}' is not in supported versions "
|
|
75
|
+
f"({', '.join(sorted(SUPPORTED_SCHEMA_VERSIONS))}). Proceeding anyway.")
|
|
76
|
+
return config
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def resolve_renode_image(config, args):
|
|
80
|
+
"""Pin the Renode version from --renode-version or the config; return
|
|
81
|
+
(git_tag, docker_image)."""
|
|
82
|
+
renode_version = getattr(args, "renode_version", None) or config.get("renode_version")
|
|
83
|
+
if not renode_version:
|
|
84
|
+
raise ValueError("Top-level 'renode_version' is required (e.g. '1.16.1'), "
|
|
85
|
+
"or pass --renode-version on the command line.")
|
|
86
|
+
normalized = str(renode_version).lstrip("v")
|
|
87
|
+
return f"v{normalized}", f"antmicro/renode:{normalized}"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _resolve_set_overrides(args):
|
|
91
|
+
overrides = {}
|
|
92
|
+
for kv in (getattr(args, "set_args", None) or []):
|
|
93
|
+
if "=" not in kv:
|
|
94
|
+
print(f"[ERROR] --set: '{kv}' is not KEY=VALUE", file=sys.stderr)
|
|
95
|
+
sys.exit(1)
|
|
96
|
+
k, _, v = kv.partition("=")
|
|
97
|
+
overrides[k.strip()] = v
|
|
98
|
+
return overrides
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _needs_renode_source(config):
|
|
102
|
+
"""The Renode source / verilator-integration checkouts are only needed to build
|
|
103
|
+
`modules:` (they compile against Renode) or to interpolate ${RENODE_DIR} /
|
|
104
|
+
${INTEGRATION_DIR}. The Renode binary itself comes from the Docker image, so plain
|
|
105
|
+
machines, build/shell steps and firmware URLs need neither checkout."""
|
|
106
|
+
if config.get("modules"):
|
|
107
|
+
return True
|
|
108
|
+
blob = str(config)
|
|
109
|
+
return "RENODE_DIR" in blob or "INTEGRATION_DIR" in blob
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def build_job_config(action, config, args, *, test_mode=False):
|
|
113
|
+
"""Assemble a JobConfig from parsed args — the shared run / test / export path:
|
|
114
|
+
pin the image, prepare the framework (lazily), resolve arguments + artifacts."""
|
|
115
|
+
git_tag, docker_image = resolve_renode_image(config, args)
|
|
116
|
+
|
|
117
|
+
# Clone Renode only when the config actually needs the source; otherwise skip it
|
|
118
|
+
# entirely (a ~200 MB clone) and leave the framework vars empty.
|
|
119
|
+
if _needs_renode_source(config):
|
|
120
|
+
renode_dir, integration_dir = prepare_dependencies(
|
|
121
|
+
git_tag,
|
|
122
|
+
renode_dir=getattr(args, "renode_dir", None),
|
|
123
|
+
integration_dir=getattr(args, "renode_integration_dir", None),
|
|
124
|
+
)
|
|
125
|
+
fw_vars = framework_vars(renode_dir, integration_dir)
|
|
126
|
+
fw_vars["_integration_dir_host"] = integration_dir
|
|
127
|
+
else:
|
|
128
|
+
fw_vars = {"RENODE_DIR": "", "INTEGRATION_DIR": "", "_integration_dir_host": ""}
|
|
129
|
+
|
|
130
|
+
resolved_args = resolve_arguments(config.get("arguments", {}),
|
|
131
|
+
overrides=_resolve_set_overrides(args))
|
|
132
|
+
artifacts_cfg = (args.artifacts if getattr(args, "artifacts", None)
|
|
133
|
+
else as_lines(config.get("artifacts", [])))
|
|
134
|
+
return JobConfig(
|
|
135
|
+
config=config,
|
|
136
|
+
fw_vars=fw_vars,
|
|
137
|
+
resolved_args=resolved_args,
|
|
138
|
+
docker_image=docker_image,
|
|
139
|
+
action=action,
|
|
140
|
+
test_mode=test_mode,
|
|
141
|
+
debug_args=getattr(args, "debug", None) or [],
|
|
142
|
+
test_file=getattr(args, "test_file", None),
|
|
143
|
+
reporter_names=_resolve_reporters(getattr(args, "reporters", None)),
|
|
144
|
+
renode_args=shlex.split(getattr(args, "renode_args", "") or ""),
|
|
145
|
+
renode_test_args=shlex.split(getattr(args, "renode_test_args", "") or ""),
|
|
146
|
+
test_name=getattr(args, "test_name", None),
|
|
147
|
+
live=getattr(args, "live", False),
|
|
148
|
+
fail_fast=getattr(args, "fail_fast", False),
|
|
149
|
+
snapshot=getattr(args, "snapshot", None),
|
|
150
|
+
output_dir=getattr(args, "output", "results"),
|
|
151
|
+
gather_metrics=getattr(args, "gather_execution_metrics", None),
|
|
152
|
+
artifacts=artifacts_cfg,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def execute_jobs(cfg, args):
|
|
157
|
+
"""Run exactly one matrix combination and return a process exit code.
|
|
158
|
+
|
|
159
|
+
hector does NOT auto-expand the matrix — iterating combinations is the CI's job.
|
|
160
|
+
A single invocation runs one combination: with no matrix there is one (empty) job;
|
|
161
|
+
with a matrix, --job KEY=VALUE must pin it to exactly one combination."""
|
|
162
|
+
jobs = expand_matrix(cfg.config.get("matrix"))
|
|
163
|
+
if getattr(args, "job", None):
|
|
164
|
+
jobs = filter_jobs(jobs, args.job)
|
|
165
|
+
|
|
166
|
+
if len(jobs) != 1:
|
|
167
|
+
listing = "\n ".join(_job_label(j) for j in jobs)
|
|
168
|
+
print(f"[ERROR] The matrix produces {len(jobs)} combinations; a run targets one. "
|
|
169
|
+
"Select it with --job KEY=VALUE (e.g. --job BOARD=stm32f4).\n"
|
|
170
|
+
f" Combinations:\n {listing}", file=sys.stderr)
|
|
171
|
+
return 2
|
|
172
|
+
|
|
173
|
+
result = run_job(0, 1, jobs[0], cfg)
|
|
174
|
+
if cfg.test_mode:
|
|
175
|
+
print_summary([result], cfg.test_mode)
|
|
176
|
+
return 1 if not result["passed"] else 0
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def snapshot_fastpath(args):
|
|
180
|
+
"""`run --snapshot --renode-version` with no .hector.yaml: write a snapshot
|
|
181
|
+
loader resc and boot it directly, skipping config/dependency machinery."""
|
|
182
|
+
if not os.path.isfile(args.snapshot):
|
|
183
|
+
print(f"[ERROR] Snapshot not found: {args.snapshot}", file=sys.stderr)
|
|
184
|
+
sys.exit(1)
|
|
185
|
+
normalized = args.renode_version.lstrip("v")
|
|
186
|
+
docker_image = f"antmicro/renode:{normalized}"
|
|
187
|
+
resc_root = os.path.join(ARTIFACTS_DIR, "resc")
|
|
188
|
+
os.makedirs(resc_root, exist_ok=True)
|
|
189
|
+
# No .hector.yaml here, so debug machine names come straight from --debug.
|
|
190
|
+
debug_config = parse_debug_flag(getattr(args, "debug", None), {}, 1, validate_nodes=False)
|
|
191
|
+
snap_resc = os.path.join(resc_root, "snapshot.resc")
|
|
192
|
+
with open(snap_resc, "w") as f:
|
|
193
|
+
f.write(snapshot_resc(to_container_path(args.snapshot), debug_config))
|
|
194
|
+
print(f"[SNAP] {args.snapshot}"
|
|
195
|
+
+ (f" (+GDB: {debug_config})" if debug_config else ""))
|
|
196
|
+
run_renode(snap_resc, docker_image, ports=list(debug_config.values()) or None,
|
|
197
|
+
extra_args=shlex.split(args.renode_args or ""), output_dir=args.output)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _run_validate():
|
|
201
|
+
"""Load .hector.yaml and report all config errors, then exit."""
|
|
202
|
+
config_path = ".hector.yaml"
|
|
203
|
+
if not os.path.exists(config_path):
|
|
204
|
+
print(f"[VALIDATE] {config_path} not found in current directory.")
|
|
205
|
+
sys.exit(1)
|
|
206
|
+
try:
|
|
207
|
+
config = load_yaml(config_path)
|
|
208
|
+
except Exception as exc:
|
|
209
|
+
print(f"[VALIDATE] Could not parse {config_path}: {exc}")
|
|
210
|
+
sys.exit(1)
|
|
211
|
+
|
|
212
|
+
print(f"[VALIDATE] Checking {config_path} ...")
|
|
213
|
+
errors, warnings = validate_config(config)
|
|
214
|
+
has_errors = print_issues(errors, warnings)
|
|
215
|
+
if has_errors:
|
|
216
|
+
print(f"\n[VALIDATE] FAILED — {len(errors)} error(s), {len(warnings)} warning(s).")
|
|
217
|
+
sys.exit(1)
|
|
218
|
+
elif warnings:
|
|
219
|
+
print(f"\n[VALIDATE] OK — 0 errors, {len(warnings)} warning(s).")
|
|
220
|
+
else:
|
|
221
|
+
print("[VALIDATE] OK")
|
|
222
|
+
sys.exit(0)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# ---------------------------------------------------------------------------
|
|
226
|
+
# Helpers
|
|
227
|
+
# ---------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
def _resolve_reporters(reporters):
|
|
230
|
+
"""Flatten the repeatable --reporters flag into an ordered, de-duplicated list.
|
|
231
|
+
|
|
232
|
+
Defaults to ['junit'] when the flag is absent. Each value may still contain
|
|
233
|
+
commas (e.g. --reporters junit,json) so older invocations keep working.
|
|
234
|
+
"""
|
|
235
|
+
names = []
|
|
236
|
+
for entry in (reporters or ["junit"]):
|
|
237
|
+
for name in str(entry).split(","):
|
|
238
|
+
name = name.strip()
|
|
239
|
+
if name and name not in names:
|
|
240
|
+
names.append(name)
|
|
241
|
+
return names or ["junit"]
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def build_hubs_registry(hubs_cfg):
|
|
245
|
+
registry = {}
|
|
246
|
+
for hub_name, hub_spec in (hubs_cfg or {}).items():
|
|
247
|
+
hub_spec = hub_spec or {}
|
|
248
|
+
htype = hub_spec.get("type")
|
|
249
|
+
if not htype:
|
|
250
|
+
raise ValueError(f"Hub '{hub_name}' is missing required 'type' "
|
|
251
|
+
f"({', '.join(sorted(HUBS.keys()))}).")
|
|
252
|
+
if htype not in HUBS:
|
|
253
|
+
raise ValueError(f"Hub '{hub_name}': unknown type '{htype}'. "
|
|
254
|
+
f"Available: {', '.join(sorted(HUBS.keys()))}.")
|
|
255
|
+
registry[hub_name] = htype
|
|
256
|
+
return registry
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def parse_debug_flag(debug_args, concrete_nodes, job_index, validate_nodes=True):
|
|
260
|
+
debug_config = {}
|
|
261
|
+
for entry in (debug_args or []):
|
|
262
|
+
if ":" not in entry:
|
|
263
|
+
raise ValueError(f"--debug: '{entry}' is not NODE:PORT (e.g. boardA:3333).")
|
|
264
|
+
node_name, _, port_str = entry.partition(":")
|
|
265
|
+
if not port_str.isdigit():
|
|
266
|
+
raise ValueError(f"--debug: port in '{entry}' must be a number.")
|
|
267
|
+
# In the snapshot fast path there is no .hector.yaml to validate against; the
|
|
268
|
+
# machine name is taken from the snapshot, so skip the membership check.
|
|
269
|
+
if validate_nodes and node_name not in concrete_nodes:
|
|
270
|
+
print(f"[WARN] --debug: '{node_name}' not in machines for job {job_index}, skipping.")
|
|
271
|
+
continue
|
|
272
|
+
debug_config[node_name] = int(port_str)
|
|
273
|
+
print(f"[DEBUG] {node_name} -> GDB on port {port_str} (CPU will be halted)")
|
|
274
|
+
return debug_config
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _job_label(job):
|
|
278
|
+
"""Human-readable matrix combination, e.g. 'BOARD=stm32f4 FW=debug.elf'."""
|
|
279
|
+
return " ".join(f"{k}={v}" for k, v in job.items()) or "(no matrix variables)"
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def filter_jobs(jobs, spec):
|
|
283
|
+
"""Filter the expanded jobs by --job SPEC: KEY=VALUE[,KEY=VALUE,...], matched against
|
|
284
|
+
the matrix variable values. Values compare as strings, so --job RATE=2 matches a
|
|
285
|
+
numeric 2 in the matrix."""
|
|
286
|
+
filters = {}
|
|
287
|
+
for part in spec.split(","):
|
|
288
|
+
part = part.strip()
|
|
289
|
+
if "=" not in part:
|
|
290
|
+
raise ValueError(f"--job: '{part}' is not KEY=VALUE.")
|
|
291
|
+
k, v = part.split("=", 1)
|
|
292
|
+
filters[k.strip()] = v.strip()
|
|
293
|
+
|
|
294
|
+
filtered = [j for j in jobs
|
|
295
|
+
if all(str(j.get(k)) == v for k, v in filters.items())]
|
|
296
|
+
if not filtered:
|
|
297
|
+
available = "\n ".join(_job_label(j) for j in jobs) if jobs else "(none)"
|
|
298
|
+
raise ValueError(
|
|
299
|
+
f"--job: no matrix combination matches {filters}.\n"
|
|
300
|
+
f" Combinations:\n {available}")
|
|
301
|
+
return filtered
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _apply_metrics(concrete_nodes, gather_metrics, output_dir, job_index):
|
|
305
|
+
"""Inject cpu EnableProfiler commands into node configs for requested machines."""
|
|
306
|
+
all_machines = None in gather_metrics
|
|
307
|
+
target_nodes = (list(concrete_nodes.keys()) if all_machines
|
|
308
|
+
else [n for n in gather_metrics if n is not None])
|
|
309
|
+
for node_name in target_nodes:
|
|
310
|
+
if node_name not in concrete_nodes:
|
|
311
|
+
print(f"[WARN] --gather-execution-metrics: '{node_name}' not in "
|
|
312
|
+
f"nodes for job {job_index}, skipping.")
|
|
313
|
+
continue
|
|
314
|
+
node_config = concrete_nodes[node_name]
|
|
315
|
+
metrics_file = os.path.join(output_dir, f"metrics_{node_name}_job_{job_index}.bin")
|
|
316
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
317
|
+
cmd = f"cpu EnableProfiler @{to_container_path(metrics_file)}"
|
|
318
|
+
existing = node_config.get("commands", "")
|
|
319
|
+
if existing:
|
|
320
|
+
if isinstance(existing, list):
|
|
321
|
+
existing = "\n".join(str(c) for c in existing)
|
|
322
|
+
node_config["commands"] = existing.strip() + f"\n{cmd}"
|
|
323
|
+
else:
|
|
324
|
+
node_config["commands"] = cmd
|
|
325
|
+
print(f"[METRICS] {node_name} -> {metrics_file}")
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
# ---------------------------------------------------------------------------
|
|
329
|
+
# Per-job pipeline
|
|
330
|
+
# ---------------------------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
@dataclass
|
|
333
|
+
class JobConfig:
|
|
334
|
+
config: dict
|
|
335
|
+
fw_vars: dict
|
|
336
|
+
resolved_args: dict
|
|
337
|
+
docker_image: str
|
|
338
|
+
# PHASE-2 behaviour for this command (simulate / test / export). run_job builds
|
|
339
|
+
# every job up to a generated resc, then hands a JobState to this callable.
|
|
340
|
+
action: object
|
|
341
|
+
debug_args: list = field(default_factory=list)
|
|
342
|
+
# Only the test command lays out results test-by-test; drives the summary format.
|
|
343
|
+
test_mode: bool = False
|
|
344
|
+
test_file: str = None
|
|
345
|
+
reporter_names: list = field(default_factory=lambda: ["junit"])
|
|
346
|
+
renode_args: list = field(default_factory=list)
|
|
347
|
+
renode_test_args: list = field(default_factory=list)
|
|
348
|
+
test_name: list = None
|
|
349
|
+
live: bool = False
|
|
350
|
+
fail_fast: bool = False
|
|
351
|
+
snapshot: str = None
|
|
352
|
+
output_dir: str = "results"
|
|
353
|
+
gather_metrics: list = None
|
|
354
|
+
artifacts: list = field(default_factory=list)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
@dataclass
|
|
358
|
+
class JobState:
|
|
359
|
+
"""Everything a command's PHASE-2 action needs once a job has been assembled
|
|
360
|
+
into a generated emulation resc."""
|
|
361
|
+
resc_filename: str
|
|
362
|
+
runtime: RuntimeOptions
|
|
363
|
+
debug_config: dict
|
|
364
|
+
tests: list
|
|
365
|
+
concrete_nodes: dict
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
# ---------------------------------------------------------------------------
|
|
369
|
+
# PHASE-2 actions (one per command; selected via JobConfig.action)
|
|
370
|
+
# ---------------------------------------------------------------------------
|
|
371
|
+
|
|
372
|
+
def _resolve_active_resc(state, cfg):
|
|
373
|
+
"""The resc a simulation/export should boot: the generated one, or a snapshot
|
|
374
|
+
loader written on the fly when --snapshot is given."""
|
|
375
|
+
if not cfg.snapshot:
|
|
376
|
+
return state.resc_filename
|
|
377
|
+
if not os.path.isfile(cfg.snapshot):
|
|
378
|
+
raise FileNotFoundError(f"Snapshot not found: {cfg.snapshot}")
|
|
379
|
+
snap_resc = state.resc_filename.replace(".resc", "_snapshot.resc")
|
|
380
|
+
with open(snap_resc, "w") as f:
|
|
381
|
+
f.write(snapshot_resc(to_container_path(cfg.snapshot), state.debug_config))
|
|
382
|
+
print(f"[SNAP] {cfg.snapshot}"
|
|
383
|
+
+ (f" (+GDB: {state.debug_config})" if state.debug_config else ""))
|
|
384
|
+
return snap_resc
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def action_simulate(state, cfg, result):
|
|
388
|
+
"""`run`: boot the emulation interactively in Renode."""
|
|
389
|
+
if not state.resc_filename:
|
|
390
|
+
raise ValueError("`hector run` needs at least one machine in 'machines:'.")
|
|
391
|
+
run_renode(_resolve_active_resc(state, cfg), cfg.docker_image,
|
|
392
|
+
ports=list(state.debug_config.values()) or None,
|
|
393
|
+
extra_args=cfg.renode_args, output_dir=cfg.output_dir,
|
|
394
|
+
runtime=state.runtime)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def action_export(state, cfg, result):
|
|
398
|
+
"""`export`: generate the files, then print the command that would run instead
|
|
399
|
+
of running it (a `docker run ... renode ...`, or a bare `renode ...` under
|
|
400
|
+
--no-docker)."""
|
|
401
|
+
if not state.resc_filename:
|
|
402
|
+
raise ValueError("`hector export` needs at least one machine in 'machines:'.")
|
|
403
|
+
argv = build_renode_argv(_resolve_active_resc(state, cfg), cfg.docker_image,
|
|
404
|
+
ports=list(state.debug_config.values()) or None,
|
|
405
|
+
extra_args=cfg.renode_args, runtime=state.runtime)
|
|
406
|
+
line = shlex.join(argv)
|
|
407
|
+
print(line)
|
|
408
|
+
result.setdefault("exported", []).append(line)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def action_test(state, cfg, result):
|
|
412
|
+
"""`test`: run the resolved `tests:` section and record pass/fail."""
|
|
413
|
+
effective_tests = state.tests
|
|
414
|
+
if cfg.test_file:
|
|
415
|
+
effective_tests = [{"name": os.path.basename(cfg.test_file),
|
|
416
|
+
"type": "robot", "file": cfg.test_file}]
|
|
417
|
+
if cfg.test_name:
|
|
418
|
+
effective_tests = [t for t in effective_tests
|
|
419
|
+
if any(n in t.get("name", "") for n in cfg.test_name)]
|
|
420
|
+
if not effective_tests:
|
|
421
|
+
print(f"[TEST] No tests matching {cfg.test_name}.")
|
|
422
|
+
if not effective_tests:
|
|
423
|
+
print("[TEST] No tests defined in 'tests:'; nothing to run.")
|
|
424
|
+
return
|
|
425
|
+
ctx = RunnerContext(
|
|
426
|
+
resc_host_path=state.resc_filename,
|
|
427
|
+
job_index=result["job_index"],
|
|
428
|
+
image=cfg.docker_image,
|
|
429
|
+
reporters=cfg.reporter_names,
|
|
430
|
+
renode_test_args=cfg.renode_test_args,
|
|
431
|
+
live=cfg.live,
|
|
432
|
+
snapshot=cfg.snapshot or "",
|
|
433
|
+
results_dir=cfg.output_dir,
|
|
434
|
+
runtime=state.runtime,
|
|
435
|
+
)
|
|
436
|
+
# run_job combines these with the build results and emits the reports once.
|
|
437
|
+
result["test_results"] = run_tests(effective_tests, ctx, fail_fast=cfg.fail_fast)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def run_job(i, total_jobs, job_context, cfg):
|
|
441
|
+
"""Execute one matrix job. Returns a result dict."""
|
|
442
|
+
label = " ".join(f"{k}={v}" for k, v in job_context.items()) if job_context else "(no matrix variables)"
|
|
443
|
+
if total_jobs > 1:
|
|
444
|
+
print(f"\n[JOB {i+1}/{total_jobs}] {label}")
|
|
445
|
+
|
|
446
|
+
context = {**cfg.fw_vars, **cfg.resolved_args, **job_context}
|
|
447
|
+
modules_cfg = interpolate(copy.deepcopy(cfg.config.get("modules", {})), context)
|
|
448
|
+
concrete_nodes = interpolate(copy.deepcopy(cfg.config.get("machines", {})), context)
|
|
449
|
+
hubs_cfg = interpolate(copy.deepcopy(cfg.config.get("hubs", {})), context)
|
|
450
|
+
top_connections = as_lines(interpolate(copy.deepcopy(cfg.config.get("connections", "")), context))
|
|
451
|
+
mappings = as_lines(interpolate(copy.deepcopy(cfg.config.get("mappings", "")), context))
|
|
452
|
+
tests = interpolate(copy.deepcopy(cfg.config.get("tests", [])), context)
|
|
453
|
+
|
|
454
|
+
hubs_registry = build_hubs_registry(hubs_cfg)
|
|
455
|
+
result = {"job_index": i + 1, "label": label, "passed": True, "test_results": None, "error": None}
|
|
456
|
+
|
|
457
|
+
# Execution-environment needs (docker caps/devices/root) declared by components
|
|
458
|
+
# (mappings, hubs, modules) during parsing; mapped to run flags at execution.
|
|
459
|
+
runtime = RuntimeOptions()
|
|
460
|
+
|
|
461
|
+
try:
|
|
462
|
+
# ---- PHASE 1: build modules, run the global build steps ----
|
|
463
|
+
build_ctx = BuildContext(
|
|
464
|
+
renode_dir_container=cfg.fw_vars["RENODE_DIR"],
|
|
465
|
+
integration_dir_host=context.get("_integration_dir_host", ""),
|
|
466
|
+
image=cfg.docker_image,
|
|
467
|
+
)
|
|
468
|
+
modules_registry = build_modules(modules_cfg, build_ctx)
|
|
469
|
+
for mod in modules_registry.values():
|
|
470
|
+
runtime.merge(mod.get("runtime"))
|
|
471
|
+
|
|
472
|
+
# Global build steps (compile firmware, fetch/generate files, ...) — run once per
|
|
473
|
+
# job, in containers, before the machines are built. Outputs land in the mounted
|
|
474
|
+
# project so the sim/tests pick them up. They report like tests (one StepResult
|
|
475
|
+
# each) and abort the job on the first failure. cfg.docker_image is the default.
|
|
476
|
+
build_blocks = interpolate(copy.deepcopy(cfg.config.get("build", [])), context)
|
|
477
|
+
build_results = run_build_steps(build_blocks, cfg.docker_image, cfg.output_dir,
|
|
478
|
+
live=cfg.live)
|
|
479
|
+
build_ok = all(r.passed for r in build_results)
|
|
480
|
+
|
|
481
|
+
for node_name, node_config in concrete_nodes.items():
|
|
482
|
+
if node_config.get("backend", "renode") != "renode":
|
|
483
|
+
raise ValueError(
|
|
484
|
+
f"Node '{node_name}': unknown backend '{node_config.get('backend')}'. "
|
|
485
|
+
"Only 'renode' is supported."
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
# ---- PHASE 1.5: collect ----
|
|
489
|
+
# Pre-pass: classify ALL connections as intra-machine or hub connections.
|
|
490
|
+
# Intra-machine lines (neither side is a hub) are injected directly into
|
|
491
|
+
# each node's _repl_signals so the generator sees them inline on the peripheral
|
|
492
|
+
# definition — no separate merge step needed.
|
|
493
|
+
# Hub connections are collected for resolve_connections (cross-machine wiring).
|
|
494
|
+
known_nodes = set(concrete_nodes)
|
|
495
|
+
hub_connections = []
|
|
496
|
+
|
|
497
|
+
for node_name, _nc in concrete_nodes.items():
|
|
498
|
+
intra, hub = [], []
|
|
499
|
+
for line in as_lines(_nc.get("connections", "")):
|
|
500
|
+
(hub if conn.is_hub_line(line, hubs_registry) else intra).append(line)
|
|
501
|
+
if intra:
|
|
502
|
+
existing = _nc.get("_repl_signals", "")
|
|
503
|
+
_nc["_repl_signals"] = ("\n".join([existing] + intra) if existing
|
|
504
|
+
else "\n".join(intra))
|
|
505
|
+
hub_connections.extend(
|
|
506
|
+
conn.qualify_node_connection(l, node_name, hubs_registry, known_nodes)
|
|
507
|
+
for l in hub)
|
|
508
|
+
|
|
509
|
+
# Top-level connections: hub lines go to resolve_connections; intra-machine
|
|
510
|
+
# top-level lines are stripped of their 'node.' prefix and injected into the
|
|
511
|
+
# owning node's _repl_signals.
|
|
512
|
+
for line in top_connections:
|
|
513
|
+
if conn.is_hub_line(line, hubs_registry):
|
|
514
|
+
hub_connections.append(line)
|
|
515
|
+
else:
|
|
516
|
+
if "->" not in line:
|
|
517
|
+
raise ValueError(
|
|
518
|
+
f"Top-level non-hub connection '{line}' must use '->'.")
|
|
519
|
+
src_tok = line.split("->", 1)[0].strip()
|
|
520
|
+
node = src_tok.split(".")[0] if "." in src_tok else None
|
|
521
|
+
if node not in concrete_nodes:
|
|
522
|
+
raise ValueError(
|
|
523
|
+
f"Top-level connection '{line}': cross-machine links require "
|
|
524
|
+
"a hub declared under 'hubs:'.")
|
|
525
|
+
pfx = node + "."
|
|
526
|
+
a, b = (t.strip() for t in line.split("->", 1))
|
|
527
|
+
stripped = (f"{a[len(pfx):]if a.startswith(pfx)else a} -> "
|
|
528
|
+
f"{b[len(pfx):]if b.startswith(pfx)else b}")
|
|
529
|
+
_nc = concrete_nodes[node]
|
|
530
|
+
existing = _nc.get("_repl_signals", "")
|
|
531
|
+
_nc["_repl_signals"] = (f"{existing}\n{stripped}" if existing else stripped)
|
|
532
|
+
|
|
533
|
+
# Build NodeDescriptors: _repl_signals is now fully populated before this call.
|
|
534
|
+
descriptors = periph.resolve_peripherals(concrete_nodes, modules_registry)
|
|
535
|
+
|
|
536
|
+
# C# DLL imports must precede any `mach create` in the resc.
|
|
537
|
+
seen_dlls = set()
|
|
538
|
+
for desc in descriptors.values():
|
|
539
|
+
seen_dlls.update(desc.csharp_dlls)
|
|
540
|
+
emulation_setup = [f"i @{to_container_path(dll)}" for dll in seen_dlls]
|
|
541
|
+
|
|
542
|
+
# so_commands go into _gen_commands before mappings to preserve [so, map, user] order.
|
|
543
|
+
for node_name, descriptor in descriptors.items():
|
|
544
|
+
for cmd in descriptor.so_commands:
|
|
545
|
+
concrete_nodes[node_name].setdefault("_gen_commands", []).append(cmd)
|
|
546
|
+
|
|
547
|
+
if mappings:
|
|
548
|
+
map_lines, map_runtime = maps.resolve_mappings(mappings, concrete_nodes)
|
|
549
|
+
emulation_setup += map_lines
|
|
550
|
+
runtime.merge(map_runtime)
|
|
551
|
+
|
|
552
|
+
cross_connections = (
|
|
553
|
+
conn.resolve_connections(hub_connections, concrete_nodes, hubs_registry)
|
|
554
|
+
if hub_connections else []
|
|
555
|
+
)
|
|
556
|
+
for spec in cross_connections:
|
|
557
|
+
runtime.merge(HUBS.get(hubs_registry[spec["hub"]]).runtime_options(spec))
|
|
558
|
+
|
|
559
|
+
# ---- PHASE 1.75: generate ----
|
|
560
|
+
# Write one .repl per node, then append its path to node_config["platform"].
|
|
561
|
+
for node_name, descriptor in descriptors.items():
|
|
562
|
+
repl_path = gen.generate_node_repl(descriptor, i + 1)
|
|
563
|
+
if repl_path:
|
|
564
|
+
concrete_nodes[node_name].setdefault("platform", []).append(
|
|
565
|
+
to_container_path(repl_path))
|
|
566
|
+
|
|
567
|
+
finalize_commands(concrete_nodes)
|
|
568
|
+
|
|
569
|
+
quantum_cfg = cfg.config.get("quantum")
|
|
570
|
+
if quantum_cfg is not None:
|
|
571
|
+
quantum_value = str(quantum_cfg)
|
|
572
|
+
elif hubs_registry:
|
|
573
|
+
quantum_value = DEFAULT_QUANTUM
|
|
574
|
+
print(f"[QUANTUM] hub(s) present -> SetGlobalQuantum {quantum_value}s "
|
|
575
|
+
"(default; set top-level 'quantum:' to override)")
|
|
576
|
+
else:
|
|
577
|
+
quantum_value = None
|
|
578
|
+
|
|
579
|
+
debug_config = parse_debug_flag(cfg.debug_args, concrete_nodes, i + 1)
|
|
580
|
+
if cfg.gather_metrics is not None:
|
|
581
|
+
_apply_metrics(concrete_nodes, cfg.gather_metrics, cfg.output_dir, i + 1)
|
|
582
|
+
|
|
583
|
+
# ---- PHASE 2: generate resc (if any machines); simulate / test ----
|
|
584
|
+
# A resc is only produced when machines are defined. The action still runs
|
|
585
|
+
# without one: `run`/`export` error (they need a machine), while `test` runs
|
|
586
|
+
# any sim-independent tests (requires_sim: false) and skips the rest.
|
|
587
|
+
resc_filename = None
|
|
588
|
+
if concrete_nodes:
|
|
589
|
+
resc_root = os.path.join(ARTIFACTS_DIR, "resc")
|
|
590
|
+
os.makedirs(resc_root, exist_ok=True)
|
|
591
|
+
resc_filename = os.path.join(resc_root, f"job_{i+1}.resc")
|
|
592
|
+
generate_emulation_resc(
|
|
593
|
+
list(concrete_nodes.items()), hubs_registry, cross_connections,
|
|
594
|
+
quantum_value, debug_config, resc_filename,
|
|
595
|
+
emulation_setup=emulation_setup,
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
# A failed build aborts before the sim/tests — but its results are still
|
|
599
|
+
# reported, so the failure shows up in junit/manifest/html like a test.
|
|
600
|
+
if build_ok:
|
|
601
|
+
state = JobState(resc_filename=resc_filename, runtime=runtime,
|
|
602
|
+
debug_config=debug_config, tests=tests,
|
|
603
|
+
concrete_nodes=concrete_nodes)
|
|
604
|
+
cfg.action(state, cfg, result)
|
|
605
|
+
else:
|
|
606
|
+
print("\n[BUILD] a build step failed — skipping simulation/tests.")
|
|
607
|
+
|
|
608
|
+
# ---- PHASE 2.5: report (build steps + tests, together) ----
|
|
609
|
+
step_results = list(build_results) + (result.get("test_results") or [])
|
|
610
|
+
result["test_results"] = step_results
|
|
611
|
+
if step_results:
|
|
612
|
+
emit(step_results, cfg.output_dir, cfg.reporter_names)
|
|
613
|
+
result["passed"] = result["passed"] and all(r.passed for r in step_results)
|
|
614
|
+
|
|
615
|
+
# ---- PHASE 3: sweep artifacts ----
|
|
616
|
+
# Global glob patterns (top-level 'artifacts:' or --artifacts), interpolated
|
|
617
|
+
# with this job's context so matrix variables resolve.
|
|
618
|
+
artifact_patterns = as_lines(interpolate(list(cfg.artifacts), context))
|
|
619
|
+
collect_artifacts(artifact_patterns, cfg.output_dir, i + 1)
|
|
620
|
+
|
|
621
|
+
except Exception as exc:
|
|
622
|
+
result["passed"] = False
|
|
623
|
+
result["error"] = str(exc)
|
|
624
|
+
print(f"\n[ERROR] Job {i+1} failed: {exc}")
|
|
625
|
+
|
|
626
|
+
return result
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
# ---------------------------------------------------------------------------
|
|
630
|
+
# Aggregate results table
|
|
631
|
+
# ---------------------------------------------------------------------------
|
|
632
|
+
|
|
633
|
+
def print_summary(all_results, test_mode):
|
|
634
|
+
n_jobs = len(all_results)
|
|
635
|
+
W = 62
|
|
636
|
+
|
|
637
|
+
# Single job in test mode: show test-level results directly — no confusing
|
|
638
|
+
# job-vs-test count mismatch.
|
|
639
|
+
if n_jobs == 1 and test_mode:
|
|
640
|
+
r = all_results[0]
|
|
641
|
+
t = r["test_results"] or []
|
|
642
|
+
n_t = len(t)
|
|
643
|
+
n_tp = sum(1 for s in t if s.passed)
|
|
644
|
+
print(f"\n{'═'*W}")
|
|
645
|
+
if r["error"]:
|
|
646
|
+
print(f"FAILED ERROR: {r['error']}")
|
|
647
|
+
else:
|
|
648
|
+
print(f"RESULTS {n_tp}/{n_t} tests passed")
|
|
649
|
+
print(f"{'─'*W}")
|
|
650
|
+
for s in t:
|
|
651
|
+
mark = "✓" if s.passed else "✗"
|
|
652
|
+
print(f" {mark} [{s.type}] {s.name}")
|
|
653
|
+
print(f"{'═'*W}")
|
|
654
|
+
return
|
|
655
|
+
|
|
656
|
+
# Matrix / multi-job: job-level header + per-job test breakdown.
|
|
657
|
+
n_pass = sum(1 for r in all_results if r["passed"])
|
|
658
|
+
print(f"\n{'═'*W}")
|
|
659
|
+
print(f"JOB SUMMARY {n_pass}/{n_jobs} jobs passed")
|
|
660
|
+
print(f"{'─'*W}")
|
|
661
|
+
for r in all_results:
|
|
662
|
+
mark = "✓" if r["passed"] else "✗"
|
|
663
|
+
label = r["label"] or f"job {r['job_index']}"
|
|
664
|
+
if r["error"]:
|
|
665
|
+
detail = f" ERROR: {r['error']}"
|
|
666
|
+
elif test_mode and r["test_results"] is not None:
|
|
667
|
+
t = r["test_results"]
|
|
668
|
+
n_tp = sum(1 for s in t if s.passed)
|
|
669
|
+
failed = [s.name for s in t if not s.passed]
|
|
670
|
+
detail = f" {n_tp}/{len(t)} tests"
|
|
671
|
+
if failed:
|
|
672
|
+
detail += " ← " + ", ".join(failed)
|
|
673
|
+
else:
|
|
674
|
+
detail = ""
|
|
675
|
+
print(f" {mark} JOB {r['job_index']} {label}{detail}")
|
|
676
|
+
print(f"{'═'*W}")
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
# ---------------------------------------------------------------------------
|
|
680
|
+
# Entry point
|
|
681
|
+
# ---------------------------------------------------------------------------
|
|
682
|
+
|
|
683
|
+
def main():
|
|
684
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
685
|
+
sys.stdout.reconfigure(line_buffering=True)
|
|
686
|
+
|
|
687
|
+
# Subcommands are discovered from the COMMANDS registry; each parses its own
|
|
688
|
+
# flags and runs via the shared engine above.
|
|
689
|
+
from .commands import build_parser
|
|
690
|
+
|
|
691
|
+
parser = build_parser()
|
|
692
|
+
args = parser.parse_args()
|
|
693
|
+
sys.exit(args._command.execute(args) or 0)
|