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/validator.py ADDED
@@ -0,0 +1,414 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """
3
+ Config validation. Collects all errors in one pass so the user sees everything at once
4
+ rather than fixing one typo at a time.
5
+
6
+ Usage:
7
+ from .validator import validate_config
8
+ errors, warnings = validate_config(raw_yaml_dict)
9
+ for e in errors: print(f" ERROR {e.path}: {e.message}")
10
+ for w in warnings: print(f" WARNING {w.path}: {w.message}")
11
+ """
12
+
13
+ import re
14
+ from dataclasses import dataclass
15
+ from typing import List, Tuple
16
+
17
+ from .core import SUPPORTED_SCHEMA_VERSIONS, as_lines
18
+ from .hubs import HUBS
19
+ from .mappings import MAPPING_BACKENDS
20
+ from .modules import MODULE_KINDS
21
+ from .runners import TEST_RUNNERS
22
+
23
+ # Top-level keys defined by this schema version; anything else is flagged.
24
+ _KNOWN_TOP_KEYS = {
25
+ "version", "renode_version", "arguments", "matrix",
26
+ "modules", "hubs", "machines", "connections", "mappings", "build", "tests", "quantum",
27
+ "artifacts", "ci",
28
+ }
29
+
30
+ # Keys renamed in this schema version → new name (for a helpful migration hint).
31
+ _RENAMED_TOP_KEYS = {"nodes": "machines", "setup": "build", "prepare": "build"}
32
+
33
+ # Test types renamed this schema version → current name (old configs still run).
34
+ _RENAMED_TEST_TYPES = {"bash": "shell", "docker": "shell"}
35
+
36
+ # CI pipeline schema (top-level `ci:` block — when/how the CI server runs this config).
37
+ _CI_EVENTS = {"push", "pull_request", "tag", "manual", "cron"}
38
+ _CI_PIPELINE_KEYS = {"when", "arguments", "jobs", "reporters", "fail_fast", "timeout", "cron"}
39
+ _CI_WHEN_KEYS = {"branch", "event", "path"}
40
+ _CI_TIMEOUT_RE = re.compile(r"^\d+[smh]$")
41
+
42
+ _KNOWN_NODE_KEYS = {
43
+ "backend", "platform", "firmware",
44
+ "peripherals", "init", "connections", "mappings", "commands",
45
+ # deprecated at machine level (now global); kept here to emit a targeted warning
46
+ # instead of a generic "unknown key" one.
47
+ "artifacts", "prepare", "build",
48
+ }
49
+
50
+ _KNOWN_MODULE_KEYS = {"kind", "type", "source", "cmake_flags"}
51
+
52
+
53
+ @dataclass
54
+ class Issue:
55
+ path: str
56
+ message: str
57
+
58
+
59
+ def validate_config(config) -> Tuple[List[Issue], List[Issue]]:
60
+ """Return (errors, warnings). errors are blockers; warnings are advisory."""
61
+ if not isinstance(config, dict):
62
+ return [Issue("(root)", "Config must be a YAML mapping, got "
63
+ f"{type(config).__name__}.")], []
64
+
65
+ errors: List[Issue] = []
66
+ warnings: List[Issue] = []
67
+
68
+ def err(path, msg):
69
+ errors.append(Issue(path, msg))
70
+
71
+ def warn(path, msg):
72
+ warnings.append(Issue(path, msg))
73
+
74
+ # ---- version ----
75
+ schema_ver = config.get("version")
76
+ if schema_ver is not None:
77
+ sv = str(schema_ver)
78
+ if sv not in SUPPORTED_SCHEMA_VERSIONS:
79
+ warn("version", f"'{sv}' is not in supported schema versions "
80
+ f"({', '.join(sorted(SUPPORTED_SCHEMA_VERSIONS))}). "
81
+ "Some fields may not be recognised.")
82
+
83
+ # ---- renode_version (required) ----
84
+ rv = config.get("renode_version")
85
+ if not rv:
86
+ err("renode_version", "Required field missing (e.g. '1.16.1').")
87
+ elif not isinstance(rv, (str, int, float)):
88
+ err("renode_version", f"Must be a string like '1.16.1', got {type(rv).__name__}.")
89
+
90
+ # ---- unknown / renamed top-level keys ----
91
+ for k in config:
92
+ if k in _RENAMED_TOP_KEYS:
93
+ warn(k, f"'{k}:' was renamed to '{_RENAMED_TOP_KEYS[k]}:' — rename the "
94
+ "top-level key. This entry is ignored.")
95
+ elif k not in _KNOWN_TOP_KEYS:
96
+ warn(k, f"Unknown top-level key '{k}' — check for a typo. "
97
+ f"Known keys: {', '.join(sorted(_KNOWN_TOP_KEYS))}.")
98
+
99
+ # ---- arguments ----
100
+ args_cfg = config.get("arguments")
101
+ if args_cfg is not None:
102
+ if not isinstance(args_cfg, dict):
103
+ err("arguments", f"Must be a mapping of name: default, got {type(args_cfg).__name__}.")
104
+ else:
105
+ for k, v in args_cfg.items():
106
+ if not isinstance(v, (str, int, float, bool)):
107
+ err(f"arguments.{k}", f"Argument default must be a scalar, got {type(v).__name__}.")
108
+
109
+ # ---- matrix ----
110
+ matrix_cfg = config.get("matrix")
111
+ if matrix_cfg is not None:
112
+ if not isinstance(matrix_cfg, dict):
113
+ err("matrix", f"Must be a mapping, got {type(matrix_cfg).__name__}.")
114
+ else:
115
+ variables = matrix_cfg.get("variables")
116
+ if variables is None:
117
+ err("matrix", "Missing 'variables' sub-key.")
118
+ elif not isinstance(variables, dict):
119
+ err("matrix.variables", f"Must be a mapping of name: [values], got {type(variables).__name__}.")
120
+ else:
121
+ for k, v in variables.items():
122
+ if not isinstance(v, list) or not v:
123
+ err(f"matrix.variables.{k}", "Must be a non-empty list of values.")
124
+ excludes = matrix_cfg.get("exclude", [])
125
+ if not isinstance(excludes, list):
126
+ err("matrix.exclude", f"Must be a list of dicts, got {type(excludes).__name__}.")
127
+
128
+ # ---- modules ----
129
+ modules_cfg = config.get("modules") or {}
130
+ if not isinstance(modules_cfg, dict):
131
+ err("modules", f"Must be a mapping, got {type(modules_cfg).__name__}.")
132
+ modules_cfg = {}
133
+ for mod_name, mod_spec in modules_cfg.items():
134
+ p = f"modules.{mod_name}"
135
+ if not isinstance(mod_spec, dict):
136
+ err(p, f"Must be a mapping, got {type(mod_spec).__name__}."); continue
137
+ kind = mod_spec.get("kind", "renode-verilator")
138
+ if kind not in MODULE_KINDS:
139
+ err(f"{p}.kind", f"Unknown module kind '{kind}'. "
140
+ f"Available: {', '.join(sorted(MODULE_KINDS.keys()))}.")
141
+ if not mod_spec.get("type"):
142
+ err(f"{p}.type", "Required: the Renode class this module exposes "
143
+ "(e.g. 'CoSimulated.CoSimulatedUART').")
144
+ if kind != "csharp" and not mod_spec.get("source"):
145
+ err(f"{p}.source", f"Required for kind '{kind}': path to the CMake/C# project.")
146
+ elif kind == "csharp" and not mod_spec.get("source"):
147
+ err(f"{p}.source", "Required: path to a .csproj directory or a pre-built .dll.")
148
+ for k in mod_spec:
149
+ if k not in _KNOWN_MODULE_KEYS:
150
+ warn(f"{p}.{k}", f"Unknown module key '{k}'.")
151
+
152
+ # ---- hubs ----
153
+ hubs_cfg = config.get("hubs") or {}
154
+ if not isinstance(hubs_cfg, dict):
155
+ err("hubs", f"Must be a mapping, got {type(hubs_cfg).__name__}.")
156
+ hubs_cfg = {}
157
+ for hub_name, hub_spec in hubs_cfg.items():
158
+ p = f"hubs.{hub_name}"
159
+ hub_spec = hub_spec or {}
160
+ if not isinstance(hub_spec, dict):
161
+ err(p, f"Must be a mapping, got {type(hub_spec).__name__}."); continue
162
+ htype = hub_spec.get("type")
163
+ if not htype:
164
+ err(f"{p}.type", f"Required. Available types: {', '.join(sorted(HUBS.keys()))}.")
165
+ elif htype not in HUBS:
166
+ err(f"{p}.type", f"Unknown hub type '{htype}'. "
167
+ f"Available: {', '.join(sorted(HUBS.keys()))}.")
168
+
169
+ # ---- machines (optional: a config may be build-only or run sim-independent
170
+ # shell tests; `run`/`export` and sim-backed tests check for one at runtime) ----
171
+ nodes_cfg = config.get("machines")
172
+ if nodes_cfg is None:
173
+ nodes_cfg = {}
174
+ elif not isinstance(nodes_cfg, dict):
175
+ err("machines", f"Must be a mapping, got {type(nodes_cfg).__name__}.")
176
+ nodes_cfg = {}
177
+ for node_name, node_spec in nodes_cfg.items():
178
+ p = f"machines.{node_name}"
179
+ if not isinstance(node_spec, dict):
180
+ err(p, f"Must be a mapping, got {type(node_spec).__name__}."); continue
181
+ backend = node_spec.get("backend", "renode")
182
+ if backend != "renode":
183
+ err(f"{p}.backend", f"Unknown backend '{backend}'. Only 'renode' is supported.")
184
+ hw = node_spec.get("platform")
185
+ if hw is not None and not isinstance(hw, list):
186
+ err(f"{p}.platform", f"Must be a list of .repl paths, got {type(hw).__name__}.")
187
+ fw = node_spec.get("firmware")
188
+ if fw is not None and not isinstance(fw, str):
189
+ err(f"{p}.firmware", f"Must be a string path or URL, got {type(fw).__name__}.")
190
+ for k in node_spec:
191
+ if k not in _KNOWN_NODE_KEYS:
192
+ warn(f"{p}.{k}", f"Unknown machine key '{k}'.")
193
+ # peripherals
194
+ perifs = node_spec.get("peripherals") or {}
195
+ if not isinstance(perifs, dict):
196
+ err(f"{p}.peripherals", f"Must be a mapping, got {type(perifs).__name__}.")
197
+ else:
198
+ for inst_name, inst_spec in perifs.items():
199
+ inst_spec = inst_spec or {}
200
+ pp = f"{p}.peripherals.{inst_name}"
201
+ if not isinstance(inst_spec, dict):
202
+ err(pp, f"Must be a mapping, got {type(inst_spec).__name__}."); continue
203
+ if not inst_spec.get("type"):
204
+ err(f"{pp}.type", "Required: either a module name or a Renode class "
205
+ "(e.g. 'Miscellaneous.Button').")
206
+ # artifacts is a top-level (global) key; per-machine entries are ignored.
207
+ if node_spec.get("artifacts") is not None:
208
+ warn(f"{p}.artifacts", "Machine-level 'artifacts:' is not supported; "
209
+ "move it to the top-level 'artifacts:' section. This entry is ignored.")
210
+ # build is a top-level (global) key; per-machine entries are ignored.
211
+ if node_spec.get("prepare") is not None or node_spec.get("build") is not None:
212
+ key = "build" if node_spec.get("build") is not None else "prepare"
213
+ warn(f"{p}.{key}", "Machine-level build/prepare is not supported; "
214
+ "move it to the top-level 'build:' section. This entry is ignored.")
215
+
216
+ # ---- connections / mappings: parse each line for obvious errors ----
217
+ _validate_connection_lines(config.get("connections", ""), "connections", err, warn)
218
+ for node_name, node_spec in nodes_cfg.items():
219
+ if isinstance(node_spec, dict):
220
+ _validate_connection_lines(
221
+ node_spec.get("connections", ""), f"machines.{node_name}.connections", err, warn)
222
+ _validate_mapping_lines(config.get("mappings", ""), "mappings", nodes_cfg, err)
223
+ for node_name, node_spec in nodes_cfg.items():
224
+ if isinstance(node_spec, dict):
225
+ _validate_mapping_lines(
226
+ node_spec.get("mappings", ""), f"machines.{node_name}.mappings", nodes_cfg, err)
227
+
228
+ # ---- artifacts (top-level, global) ----
229
+ artifacts_cfg = config.get("artifacts")
230
+ if artifacts_cfg is not None and not isinstance(artifacts_cfg, (list, str)):
231
+ err("artifacts", "Must be a list of glob patterns or a block string, got "
232
+ f"{type(artifacts_cfg).__name__}.")
233
+
234
+ # ---- build (top-level, global): pre-sim shell steps, same shape as shell tests ----
235
+ build_cfg = config.get("build")
236
+ if build_cfg is not None:
237
+ if not isinstance(build_cfg, list):
238
+ err("build", "Must be a list of build steps "
239
+ f"(each a mapping with image:/script:/steps:), got {type(build_cfg).__name__}.")
240
+ else:
241
+ for idx, block in enumerate(build_cfg):
242
+ bp = f"build[{idx}]"
243
+ if not isinstance(block, dict):
244
+ err(bp, f"Each build step must be a mapping, got {type(block).__name__}.")
245
+ continue
246
+ if block.get("image") is not None and not isinstance(block.get("image"), str):
247
+ err(f"{bp}.image", "If set, 'image' must be a string (e.g. 'arm-gcc:13').")
248
+ _validate_shell_steps(block, bp, err, warn)
249
+
250
+ # ---- tests ----
251
+ tests_cfg = config.get("tests") or []
252
+ if not isinstance(tests_cfg, list):
253
+ err("tests", f"Must be a list of test dicts, got {type(tests_cfg).__name__}.")
254
+ else:
255
+ for idx, test in enumerate(tests_cfg):
256
+ p = f"tests[{idx}]"
257
+ if not isinstance(test, dict):
258
+ err(p, f"Each test must be a mapping, got {type(test).__name__}."); continue
259
+ if not test.get("name"):
260
+ warn(p, "Test has no 'name' — consider adding one for readable output.")
261
+ rtype = test.get("type", "robot")
262
+ if rtype in _RENAMED_TEST_TYPES:
263
+ warn(f"{p}.type", f"Test type '{rtype}' was renamed to "
264
+ f"'{_RENAMED_TEST_TYPES[rtype]}' — update 'type:'.")
265
+ rtype = _RENAMED_TEST_TYPES[rtype]
266
+ if rtype not in TEST_RUNNERS:
267
+ err(f"{p}.type", f"Unknown test type '{rtype}'. "
268
+ f"Available: {', '.join(sorted(TEST_RUNNERS.keys()))}.")
269
+ if "requires_sim" in test and not isinstance(test["requires_sim"], bool):
270
+ err(f"{p}.requires_sim", "Must be true or false.")
271
+ if rtype == "robot" and test.get("requires_sim") is False:
272
+ warn(f"{p}.requires_sim", "robot tests always need the simulation; "
273
+ "'requires_sim: false' is ignored.")
274
+ _validate_shell_steps(test, p, err, warn)
275
+
276
+ # ---- quantum ----
277
+ quantum = config.get("quantum")
278
+ if quantum is not None:
279
+ try:
280
+ q = float(quantum)
281
+ if q <= 0:
282
+ err("quantum", f"Must be a positive number, got {quantum}.")
283
+ except (TypeError, ValueError):
284
+ err("quantum", f"Must be a positive number (seconds), got '{quantum}'.")
285
+
286
+ # ---- ci (CI pipelines) ----
287
+ _validate_ci(config.get("ci"), err, warn)
288
+
289
+ return errors, warnings
290
+
291
+
292
+ # ---- helpers ----
293
+
294
+ def _validate_shell_steps(item, p, err, warn):
295
+ """Validate the step structure shared by tests and build blocks: either a `steps:`
296
+ list, or a single-step `script:`/`file:` shorthand at the item level."""
297
+ if "steps" in item:
298
+ steps = item["steps"]
299
+ if not isinstance(steps, list):
300
+ err(f"{p}.steps", f"Must be a list, got {type(steps).__name__}.")
301
+ elif not steps:
302
+ warn(p, "Empty 'steps' list — it will do nothing.")
303
+ else:
304
+ for sidx, step in enumerate(steps):
305
+ sp = f"{p}.steps[{sidx}]"
306
+ if not isinstance(step, dict):
307
+ err(sp, f"Each step must be a mapping, got {type(step).__name__}.")
308
+ continue
309
+ if not step.get("name"):
310
+ warn(sp, "Step has no 'name' — consider adding one.")
311
+ if not step.get("script") and not step.get("file"):
312
+ warn(sp, "Step has no 'script' or 'file' — it will do nothing.")
313
+ if step.get("file") and step.get("script"):
314
+ warn(sp, "Step has both 'file' and 'script' — 'file' takes precedence.")
315
+ else:
316
+ # Backward compat: script/file at the item level (single-step shorthand).
317
+ if not item.get("script") and not item.get("file"):
318
+ warn(p, "No 'steps', 'script', or 'file' — it will do nothing.")
319
+ if item.get("file") and item.get("script"):
320
+ warn(p, "Both 'file' and 'script' — 'file' takes precedence.")
321
+
322
+
323
+ def _validate_ci(ci_cfg, err, warn):
324
+ """Validate the top-level `ci:` block (pipeline-name -> CI settings)."""
325
+ if ci_cfg is None:
326
+ return
327
+ if not isinstance(ci_cfg, dict):
328
+ err("ci", f"Must be a mapping of pipeline-name: settings, got {type(ci_cfg).__name__}.")
329
+ return
330
+ for name, pipe in ci_cfg.items():
331
+ p = f"ci.{name}"
332
+ if not isinstance(pipe, dict):
333
+ err(p, f"Pipeline must be a mapping, got {type(pipe).__name__}."); continue
334
+ for k in pipe:
335
+ if k not in _CI_PIPELINE_KEYS:
336
+ warn(f"{p}.{k}", f"Unknown pipeline key '{k}'. "
337
+ f"Known keys: {', '.join(sorted(_CI_PIPELINE_KEYS))}.")
338
+
339
+ # when: { branch, event, path }
340
+ when = pipe.get("when")
341
+ events = []
342
+ if when is not None:
343
+ if not isinstance(when, dict):
344
+ err(f"{p}.when", f"Must be a mapping, got {type(when).__name__}.")
345
+ else:
346
+ for k in when:
347
+ if k not in _CI_WHEN_KEYS:
348
+ warn(f"{p}.when.{k}", f"Unknown 'when' key '{k}'.")
349
+ raw_event = when.get("event")
350
+ if raw_event is None:
351
+ events = []
352
+ elif not isinstance(raw_event, list):
353
+ err(f"{p}.when.event", f"Must be a list, got {type(raw_event).__name__}.")
354
+ events = []
355
+ else:
356
+ events = raw_event
357
+ for ev in events:
358
+ if not isinstance(ev, str) or ev not in _CI_EVENTS:
359
+ err(f"{p}.when.event", f"Unknown event {ev!r}. "
360
+ f"Allowed: {', '.join(sorted(_CI_EVENTS))}.")
361
+
362
+ # jobs / fail_fast
363
+ jobs = pipe.get("jobs")
364
+ if jobs is not None and (not isinstance(jobs, int) or isinstance(jobs, bool) or jobs < 1):
365
+ err(f"{p}.jobs", f"Must be a positive integer, got {jobs!r}.")
366
+ if pipe.get("fail_fast") is True and (jobs or 1) != 1:
367
+ err(f"{p}.fail_fast", "Only valid with jobs: 1.")
368
+
369
+ # cron (required iff event includes 'cron')
370
+ has_cron_event = "cron" in events
371
+ if has_cron_event and not isinstance(pipe.get("cron"), str):
372
+ err(f"{p}.cron", "A cron expression is required when event includes 'cron'.")
373
+ if isinstance(pipe.get("cron"), str) and not has_cron_event:
374
+ warn(f"{p}.cron", "cron is set but when.event does not include 'cron'.")
375
+
376
+ # timeout / reporters / arguments
377
+ timeout = pipe.get("timeout")
378
+ if timeout is not None and (not isinstance(timeout, str) or not _CI_TIMEOUT_RE.match(timeout)):
379
+ err(f"{p}.timeout", "Must look like '30m', '90s' or '2h'.")
380
+ reporters = pipe.get("reporters")
381
+ if reporters is not None and not isinstance(reporters, list):
382
+ err(f"{p}.reporters", f"Must be a list, got {type(reporters).__name__}.")
383
+ arguments = pipe.get("arguments")
384
+ if arguments is not None and not isinstance(arguments, dict):
385
+ err(f"{p}.arguments", f"Must be a mapping of name: value, got {type(arguments).__name__}.")
386
+
387
+
388
+ def _validate_connection_lines(section, path, err, warn):
389
+ for line in as_lines(section):
390
+ if "->" not in line and "<->" not in line:
391
+ err(path, f"Line '{line}' has no '->' or '<->' operator.")
392
+
393
+
394
+ def _validate_mapping_lines(section, path, nodes_cfg, err):
395
+ for line in as_lines(section):
396
+ if "->" not in line:
397
+ err(path, f"Mapping '{line}' has no '->' operator.")
398
+ continue
399
+ _, rhs = line.split("->", 1)
400
+ backend = rhs.strip().split(":", 1)[0].strip()
401
+ if backend not in MAPPING_BACKENDS:
402
+ err(path, f"Mapping '{line}': unknown backend '{backend}'. "
403
+ f"Available: {', '.join(sorted(MAPPING_BACKENDS.keys()))}.")
404
+
405
+
406
+ def print_issues(errors, warnings):
407
+ """Pretty-print validation results. Returns True if there are errors."""
408
+ if warnings:
409
+ for w in warnings:
410
+ print(f" WARN {w.path}: {w.message}")
411
+ if errors:
412
+ for e in errors:
413
+ print(f" ERROR {e.path}: {e.message}")
414
+ return bool(errors)