hector-cli 0.3.0__tar.gz → 0.4.0__tar.gz

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.
Files changed (55) hide show
  1. {hector_cli-0.3.0/hector_cli.egg-info → hector_cli-0.4.0}/PKG-INFO +49 -7
  2. {hector_cli-0.3.0 → hector_cli-0.4.0}/README.md +48 -6
  3. {hector_cli-0.3.0 → hector_cli-0.4.0}/hector/commands/__init__.py +1 -1
  4. {hector_cli-0.3.0 → hector_cli-0.4.0}/hector/commands/base.py +6 -0
  5. hector_cli-0.4.0/hector/commands/clean.py +34 -0
  6. {hector_cli-0.3.0 → hector_cli-0.4.0}/hector/commands/run.py +2 -2
  7. {hector_cli-0.3.0 → hector_cli-0.4.0}/hector/core.py +1 -1
  8. {hector_cli-0.3.0 → hector_cli-0.4.0}/hector/pipeline.py +111 -12
  9. {hector_cli-0.3.0 → hector_cli-0.4.0}/hector/scaffold.py +1 -1
  10. {hector_cli-0.3.0 → hector_cli-0.4.0}/hector/validator.py +20 -81
  11. {hector_cli-0.3.0 → hector_cli-0.4.0/hector_cli.egg-info}/PKG-INFO +49 -7
  12. {hector_cli-0.3.0 → hector_cli-0.4.0}/hector_cli.egg-info/SOURCES.txt +22 -1
  13. hector_cli-0.4.0/tests/test_arguments.py +32 -0
  14. hector_cli-0.4.0/tests/test_clean.py +103 -0
  15. hector_cli-0.4.0/tests/test_cli_smoke.py +74 -0
  16. hector_cli-0.4.0/tests/test_connections.py +124 -0
  17. hector_cli-0.4.0/tests/test_core_paths.py +98 -0
  18. hector_cli-0.4.0/tests/test_core_registry.py +75 -0
  19. hector_cli-0.4.0/tests/test_core_text.py +78 -0
  20. hector_cli-0.4.0/tests/test_docker_argv.py +101 -0
  21. hector_cli-0.4.0/tests/test_generator.py +196 -0
  22. hector_cli-0.4.0/tests/test_hubs.py +53 -0
  23. hector_cli-0.4.0/tests/test_mappings.py +102 -0
  24. hector_cli-0.4.0/tests/test_matrix.py +30 -0
  25. hector_cli-0.4.0/tests/test_needs_renode_source.py +38 -0
  26. hector_cli-0.4.0/tests/test_peripherals.py +81 -0
  27. hector_cli-0.4.0/tests/test_pipeline_helpers.py +149 -0
  28. hector_cli-0.4.0/tests/test_reporters.py +70 -0
  29. hector_cli-0.4.0/tests/test_runners.py +83 -0
  30. hector_cli-0.4.0/tests/test_scaffold.py +53 -0
  31. hector_cli-0.4.0/tests/test_validator.py +141 -0
  32. hector_cli-0.4.0/tests/test_version.py +39 -0
  33. {hector_cli-0.3.0 → hector_cli-0.4.0}/LICENSE +0 -0
  34. {hector_cli-0.3.0 → hector_cli-0.4.0}/hector/__init__.py +0 -0
  35. {hector_cli-0.3.0 → hector_cli-0.4.0}/hector/__main__.py +0 -0
  36. {hector_cli-0.3.0 → hector_cli-0.4.0}/hector/commands/export.py +0 -0
  37. {hector_cli-0.3.0 → hector_cli-0.4.0}/hector/commands/init.py +0 -0
  38. {hector_cli-0.3.0 → hector_cli-0.4.0}/hector/commands/test.py +0 -0
  39. {hector_cli-0.3.0 → hector_cli-0.4.0}/hector/commands/validate.py +0 -0
  40. {hector_cli-0.3.0 → hector_cli-0.4.0}/hector/connections.py +0 -0
  41. {hector_cli-0.3.0 → hector_cli-0.4.0}/hector/dependencies.py +0 -0
  42. {hector_cli-0.3.0 → hector_cli-0.4.0}/hector/docker.py +0 -0
  43. {hector_cli-0.3.0 → hector_cli-0.4.0}/hector/generator.py +0 -0
  44. {hector_cli-0.3.0 → hector_cli-0.4.0}/hector/hubs.py +0 -0
  45. {hector_cli-0.3.0 → hector_cli-0.4.0}/hector/mappings.py +0 -0
  46. {hector_cli-0.3.0 → hector_cli-0.4.0}/hector/modules.py +0 -0
  47. {hector_cli-0.3.0 → hector_cli-0.4.0}/hector/peripherals.py +0 -0
  48. {hector_cli-0.3.0 → hector_cli-0.4.0}/hector/reporters.py +0 -0
  49. {hector_cli-0.3.0 → hector_cli-0.4.0}/hector/runners.py +0 -0
  50. {hector_cli-0.3.0 → hector_cli-0.4.0}/hector_cli.egg-info/dependency_links.txt +0 -0
  51. {hector_cli-0.3.0 → hector_cli-0.4.0}/hector_cli.egg-info/entry_points.txt +0 -0
  52. {hector_cli-0.3.0 → hector_cli-0.4.0}/hector_cli.egg-info/requires.txt +0 -0
  53. {hector_cli-0.3.0 → hector_cli-0.4.0}/hector_cli.egg-info/top_level.txt +0 -0
  54. {hector_cli-0.3.0 → hector_cli-0.4.0}/setup.cfg +0 -0
  55. {hector_cli-0.3.0 → hector_cli-0.4.0}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hector-cli
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: YAML-driven Renode + Verilator simulation orchestrator for embedded hardware CI
5
5
  Home-page: https://hector-ci.com
6
6
  Author: Nemesis
@@ -205,6 +205,7 @@ the CLI natively with `pip` and let hector launch real per-step containers.
205
205
  | `hector run --gather-execution-metrics boardA` | Enable profiler for one specific machine |
206
206
  | `hector run --gather-execution-metrics boardA --gather-execution-metrics boardB` | Enable profiler for selected machines |
207
207
  | `hector run --renode-version 1.16.1` | Override the Renode version from `.hector.yaml` |
208
+ | `hector run --renode-image my-renode:dev` | Use a specific (e.g. self-built) Renode Docker image |
208
209
  | `hector run --renode-dir /cache/renode` | Use/cache the Renode source checkout at this path (only fetched when needed) |
209
210
  | `hector run --renode-integration-dir /cache/integration` | Likewise for the verilator-integration checkout |
210
211
  | `hector run --no-docker` | Use locally installed `renode` / `renode-test` instead of Docker |
@@ -229,11 +230,15 @@ the CLI natively with `pip` and let hector launch real per-step containers.
229
230
  | `hector validate -f board.yaml` | Validate a config file by another name |
230
231
  | `hector init` | Scaffold a starter `.hector.yaml` |
231
232
  | `hector init -f board.yaml` | Scaffold the starter config under a custom name |
233
+ | `hector clean` | Remove generated build artifacts (`.hector/resc,repl,tests`) and `results/`; keeps the Renode cache |
234
+ | `hector clean --all` | Also remove the cached Renode source checkouts under `.hector/` (forces a re-clone next build) |
235
+ | `hector clean --output my-results` | Clean a custom results directory instead of `results/` |
236
+ | `hector clean --dry-run` | Show what would be removed without deleting anything |
232
237
  | `hector --version` | Print the tool version |
233
238
 
234
239
  `hector` is structured as subcommands: `run` (simulate), `test` (run the `tests:`
235
- section), `export` (emit the command without running it), plus `init` and
236
- `validate`. Run `hector <command> --help` for the flags each one accepts.
240
+ section), `export` (emit the command without running it), plus `init`, `validate`,
241
+ and `clean`. Run `hector <command> --help` for the flags each one accepts.
237
242
 
238
243
  Every command reads (or, for `init`, writes) `.hector.yaml` by default. Point it
239
244
  at a different file with **`-f` / `--file`** — useful when one project keeps
@@ -338,7 +343,7 @@ hector run --snapshot results/step_1_Linux_boots/snapshot.save
338
343
  hector test --snapshot results/step_1_Linux_boots/snapshot.save
339
344
  ```
340
345
 
341
- When used with `--renode-version`, no `.hector.yaml` is needed at all — useful for inspecting a snapshot outside of any project:
346
+ When used with `--renode-version` (or `--renode-image`), no `.hector.yaml` is needed at all — useful for inspecting a snapshot outside of any project:
342
347
 
343
348
  ```bash
344
349
  hector run --snapshot /path/to/snapshot.save --renode-version 1.16.1
@@ -503,6 +508,20 @@ When combined with `--snapshot` in simulation mode, `.hector.yaml` is not requir
503
508
  hector run --snapshot path/to/snapshot.save --renode-version 1.16.1
504
509
  ```
505
510
 
511
+ #### Using a custom Renode image (`--renode-image`)
512
+
513
+ By default the image is `antmicro/renode:<renode_version>`. To use any other image — a nightly, a mirror in your registry, or one you built yourself — set `renode_image:` in `.hector.yaml` or pass `--renode-image`:
514
+
515
+ ```yaml
516
+ renode_image: my-renode:dev
517
+ ```
518
+
519
+ ```bash
520
+ hector test --renode-image ghcr.io/acme/renode:1.16.1-patched
521
+ ```
522
+
523
+ Precedence: CLI flags beat the config, and either flag replaces *both* config fields (so `--renode-version 1.15.0` wins over a `renode_image:` in the file). If both fields are set in the config, `renode_image` is the image that runs and `renode_version` only picks the Renode source tag to clone. That tag is otherwise inferred from the image tag when it looks like a version (`…:1.16.1` / `…:v1.16.1`); if the config needs the source (see below) and it can't be inferred, also set `renode_version`.
524
+
506
525
  #### Renode source checkout
507
526
 
508
527
  Running a simulation does **not** need the Renode source — the binary and its bundled platforms come from the `antmicro/renode` Docker image. Hector clones the Renode source (and the verilator-integration repo) into `.hector/` **only when a config actually needs it**:
@@ -600,7 +619,8 @@ The configuration lives in `.hector.yaml`.
600
619
  | Field | Required | Description |
601
620
  |---|---|---|
602
621
  | `version` | No | Schema version (currently `"0.1"`). Warns if unrecognised. |
603
- | `renode_version` | Yes | Renode version, no leading `v` (e.g. `1.16.1`). Selects both the git tag for cloning and the Docker image tag. |
622
+ | `renode_version` | Yes¹ | Renode version, no leading `v` (e.g. `1.16.1`). Selects both the git tag for cloning and the Docker image tag. |
623
+ | `renode_image` | Yes¹ | Full Renode Docker image to use instead of `antmicro/renode:<renode_version>` (e.g. a self-built `my-renode:dev`). See [custom Renode image](#using-a-custom-renode-image---renode-image). |
604
624
  | `arguments` | No | Scalar defaults, overridable by env var or `--set`. |
605
625
  | `build` | No | Pre-sim shell steps run once per job in containers (compile firmware, fetch/generate files, …). Interpolated with the job's arguments/matrix variables. |
606
626
  | `matrix` | No | Cross-product job expansion. |
@@ -613,6 +633,8 @@ The configuration lives in `.hector.yaml`.
613
633
  | `quantum` | No | Override the global time quantum (seconds). |
614
634
  | `ci` | No | CI pipeline definitions: when and how a CI server runs this config. |
615
635
 
636
+ ¹ At least one of `renode_version` / `renode_image` is required (or the matching CLI flag).
637
+
616
638
  ---
617
639
 
618
640
  ### arguments
@@ -938,8 +960,26 @@ machines:
938
960
 
939
961
  - **`repl:`** is written to `.hector/repl/job_<n>_<node>.inline.repl` and loaded with
940
962
  `machine LoadPlatformDescription` (Renode only reads platform descriptions from
941
- disk). It is loaded **after** any `platform:` files, so it can extend or override
942
- them — list a released board under `platform:` and patch it with `repl:`.
963
+ disk). It is loaded **after** any `platform:` files.
964
+
965
+ > **Patching a bundled platform: use `using`, not `platform:`.** Every
966
+ > `LoadPlatformDescription` is its **own description scope**, so an entry that another
967
+ > file created is only partly reachable from `repl:` — setting a *constructor*
968
+ > attribute on it fails with
969
+ > `E35: Constructor attribute within entry for variable that is not created`.
970
+ > To override one, pull the base platform into the *same* scope from inside `repl:`:
971
+ >
972
+ > ```yaml
973
+ > repl: |
974
+ > using "platforms/boards/nucleo_h753zi.repl"
975
+ >
976
+ > nvic:
977
+ > priorityMask: 0xFF # works: same scope that creates `nvic`
978
+ > ```
979
+ >
980
+ > This is what Renode's own scripts do with `LoadPlatformDescriptionFromString`.
981
+ > Use `platform:` alone when you're loading a board as-is, and `using` inside `repl:`
982
+ > when you need to patch it. See [`examples/nucleo-h753zi`](examples/nucleo-h753zi).
943
983
  - **`resc:`** is emitted verbatim into that machine's block, **after** the platform
944
984
  and firmware load and **before** `commands:`.
945
985
 
@@ -966,6 +1006,8 @@ other field. Worked examples:
966
1006
  - [`examples/nucleo-f767zi`](examples/nucleo-f767zi) — the real motivation: a
967
1007
  **NUCLEO-F767ZI**, which Renode 1.16.1 ships no `.repl` for at all, defined inline
968
1008
  and running against the pinned release image.
1009
+ - [`examples/nucleo-h753zi`](examples/nucleo-h753zi) — the other side: a board that
1010
+ **is** shipped, loaded via `using` and *patched* inline, booting Hubris.
969
1011
 
970
1012
  #### peripherals
971
1013
 
@@ -175,6 +175,7 @@ the CLI natively with `pip` and let hector launch real per-step containers.
175
175
  | `hector run --gather-execution-metrics boardA` | Enable profiler for one specific machine |
176
176
  | `hector run --gather-execution-metrics boardA --gather-execution-metrics boardB` | Enable profiler for selected machines |
177
177
  | `hector run --renode-version 1.16.1` | Override the Renode version from `.hector.yaml` |
178
+ | `hector run --renode-image my-renode:dev` | Use a specific (e.g. self-built) Renode Docker image |
178
179
  | `hector run --renode-dir /cache/renode` | Use/cache the Renode source checkout at this path (only fetched when needed) |
179
180
  | `hector run --renode-integration-dir /cache/integration` | Likewise for the verilator-integration checkout |
180
181
  | `hector run --no-docker` | Use locally installed `renode` / `renode-test` instead of Docker |
@@ -199,11 +200,15 @@ the CLI natively with `pip` and let hector launch real per-step containers.
199
200
  | `hector validate -f board.yaml` | Validate a config file by another name |
200
201
  | `hector init` | Scaffold a starter `.hector.yaml` |
201
202
  | `hector init -f board.yaml` | Scaffold the starter config under a custom name |
203
+ | `hector clean` | Remove generated build artifacts (`.hector/resc,repl,tests`) and `results/`; keeps the Renode cache |
204
+ | `hector clean --all` | Also remove the cached Renode source checkouts under `.hector/` (forces a re-clone next build) |
205
+ | `hector clean --output my-results` | Clean a custom results directory instead of `results/` |
206
+ | `hector clean --dry-run` | Show what would be removed without deleting anything |
202
207
  | `hector --version` | Print the tool version |
203
208
 
204
209
  `hector` is structured as subcommands: `run` (simulate), `test` (run the `tests:`
205
- section), `export` (emit the command without running it), plus `init` and
206
- `validate`. Run `hector <command> --help` for the flags each one accepts.
210
+ section), `export` (emit the command without running it), plus `init`, `validate`,
211
+ and `clean`. Run `hector <command> --help` for the flags each one accepts.
207
212
 
208
213
  Every command reads (or, for `init`, writes) `.hector.yaml` by default. Point it
209
214
  at a different file with **`-f` / `--file`** — useful when one project keeps
@@ -308,7 +313,7 @@ hector run --snapshot results/step_1_Linux_boots/snapshot.save
308
313
  hector test --snapshot results/step_1_Linux_boots/snapshot.save
309
314
  ```
310
315
 
311
- When used with `--renode-version`, no `.hector.yaml` is needed at all — useful for inspecting a snapshot outside of any project:
316
+ When used with `--renode-version` (or `--renode-image`), no `.hector.yaml` is needed at all — useful for inspecting a snapshot outside of any project:
312
317
 
313
318
  ```bash
314
319
  hector run --snapshot /path/to/snapshot.save --renode-version 1.16.1
@@ -473,6 +478,20 @@ When combined with `--snapshot` in simulation mode, `.hector.yaml` is not requir
473
478
  hector run --snapshot path/to/snapshot.save --renode-version 1.16.1
474
479
  ```
475
480
 
481
+ #### Using a custom Renode image (`--renode-image`)
482
+
483
+ By default the image is `antmicro/renode:<renode_version>`. To use any other image — a nightly, a mirror in your registry, or one you built yourself — set `renode_image:` in `.hector.yaml` or pass `--renode-image`:
484
+
485
+ ```yaml
486
+ renode_image: my-renode:dev
487
+ ```
488
+
489
+ ```bash
490
+ hector test --renode-image ghcr.io/acme/renode:1.16.1-patched
491
+ ```
492
+
493
+ Precedence: CLI flags beat the config, and either flag replaces *both* config fields (so `--renode-version 1.15.0` wins over a `renode_image:` in the file). If both fields are set in the config, `renode_image` is the image that runs and `renode_version` only picks the Renode source tag to clone. That tag is otherwise inferred from the image tag when it looks like a version (`…:1.16.1` / `…:v1.16.1`); if the config needs the source (see below) and it can't be inferred, also set `renode_version`.
494
+
476
495
  #### Renode source checkout
477
496
 
478
497
  Running a simulation does **not** need the Renode source — the binary and its bundled platforms come from the `antmicro/renode` Docker image. Hector clones the Renode source (and the verilator-integration repo) into `.hector/` **only when a config actually needs it**:
@@ -570,7 +589,8 @@ The configuration lives in `.hector.yaml`.
570
589
  | Field | Required | Description |
571
590
  |---|---|---|
572
591
  | `version` | No | Schema version (currently `"0.1"`). Warns if unrecognised. |
573
- | `renode_version` | Yes | Renode version, no leading `v` (e.g. `1.16.1`). Selects both the git tag for cloning and the Docker image tag. |
592
+ | `renode_version` | Yes¹ | Renode version, no leading `v` (e.g. `1.16.1`). Selects both the git tag for cloning and the Docker image tag. |
593
+ | `renode_image` | Yes¹ | Full Renode Docker image to use instead of `antmicro/renode:<renode_version>` (e.g. a self-built `my-renode:dev`). See [custom Renode image](#using-a-custom-renode-image---renode-image). |
574
594
  | `arguments` | No | Scalar defaults, overridable by env var or `--set`. |
575
595
  | `build` | No | Pre-sim shell steps run once per job in containers (compile firmware, fetch/generate files, …). Interpolated with the job's arguments/matrix variables. |
576
596
  | `matrix` | No | Cross-product job expansion. |
@@ -583,6 +603,8 @@ The configuration lives in `.hector.yaml`.
583
603
  | `quantum` | No | Override the global time quantum (seconds). |
584
604
  | `ci` | No | CI pipeline definitions: when and how a CI server runs this config. |
585
605
 
606
+ ¹ At least one of `renode_version` / `renode_image` is required (or the matching CLI flag).
607
+
586
608
  ---
587
609
 
588
610
  ### arguments
@@ -908,8 +930,26 @@ machines:
908
930
 
909
931
  - **`repl:`** is written to `.hector/repl/job_<n>_<node>.inline.repl` and loaded with
910
932
  `machine LoadPlatformDescription` (Renode only reads platform descriptions from
911
- disk). It is loaded **after** any `platform:` files, so it can extend or override
912
- them — list a released board under `platform:` and patch it with `repl:`.
933
+ disk). It is loaded **after** any `platform:` files.
934
+
935
+ > **Patching a bundled platform: use `using`, not `platform:`.** Every
936
+ > `LoadPlatformDescription` is its **own description scope**, so an entry that another
937
+ > file created is only partly reachable from `repl:` — setting a *constructor*
938
+ > attribute on it fails with
939
+ > `E35: Constructor attribute within entry for variable that is not created`.
940
+ > To override one, pull the base platform into the *same* scope from inside `repl:`:
941
+ >
942
+ > ```yaml
943
+ > repl: |
944
+ > using "platforms/boards/nucleo_h753zi.repl"
945
+ >
946
+ > nvic:
947
+ > priorityMask: 0xFF # works: same scope that creates `nvic`
948
+ > ```
949
+ >
950
+ > This is what Renode's own scripts do with `LoadPlatformDescriptionFromString`.
951
+ > Use `platform:` alone when you're loading a board as-is, and `using` inside `repl:`
952
+ > when you need to patch it. See [`examples/nucleo-h753zi`](examples/nucleo-h753zi).
913
953
  - **`resc:`** is emitted verbatim into that machine's block, **after** the platform
914
954
  and firmware load and **before** `commands:`.
915
955
 
@@ -936,6 +976,8 @@ other field. Worked examples:
936
976
  - [`examples/nucleo-f767zi`](examples/nucleo-f767zi) — the real motivation: a
937
977
  **NUCLEO-F767ZI**, which Renode 1.16.1 ships no `.repl` for at all, defined inline
938
978
  and running against the pinned release image.
979
+ - [`examples/nucleo-h753zi`](examples/nucleo-h753zi) — the other side: a board that
980
+ **is** shipped, loaded via `using` and *patched* inline, booting Hubris.
939
981
 
940
982
  #### peripherals
941
983
 
@@ -12,7 +12,7 @@ from ..core import TOOL_VERSION
12
12
  from .base import COMMANDS, Command, add_common_arguments, add_simulation_arguments
13
13
 
14
14
  # Importing each module runs its @COMMANDS.register(...) decorator.
15
- from . import run, test, export, init, validate # noqa: E402,F401
15
+ from . import run, test, export, init, validate, clean # noqa: E402,F401
16
16
 
17
17
  __all__ = ["COMMANDS", "Command", "build_parser",
18
18
  "add_common_arguments", "add_simulation_arguments"]
@@ -53,6 +53,12 @@ def add_common_arguments(parser):
53
53
  help="Renode version to use (e.g. 1.16.1). Overrides renode_version from "
54
54
  ".hector.yaml.",
55
55
  )
56
+ parser.add_argument(
57
+ "--renode-image", metavar="IMAGE", dest="renode_image",
58
+ help="Renode Docker image to use instead of antmicro/renode:<version> (any "
59
+ "image, e.g. a self-built one: my-renode:dev). Overrides renode_image / "
60
+ "renode_version from .hector.yaml.",
61
+ )
56
62
  parser.add_argument(
57
63
  "--renode-dir", metavar="PATH", dest="renode_dir", default=None,
58
64
  help="Location of the Renode source checkout (only fetched when a config builds "
@@ -0,0 +1,34 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """`hector clean` — remove the files hector generates (the .hector/ build artifacts
3
+ and the results directory), so a project can be reset to a pristine checkout."""
4
+
5
+ from .. import pipeline
6
+ from .base import COMMANDS, Command
7
+
8
+
9
+ @COMMANDS.register("clean")
10
+ class CleanCommand(Command):
11
+ name = "clean"
12
+ help = "Remove generated files (.hector/ build outputs and the results directory)."
13
+
14
+ def add_arguments(self, parser):
15
+ parser.add_argument(
16
+ "--output", metavar="DIR", default="results",
17
+ help="Results directory to remove (default: results). Match the --output "
18
+ "you ran with if you used a custom one.",
19
+ )
20
+ parser.add_argument(
21
+ "--all", action="store_true", dest="all",
22
+ help="Also remove the cached Renode / verilator-integration source "
23
+ "checkouts under .hector/ — a full re-clone (~200 MB) happens on the "
24
+ "next build. Omit to keep them for fast iteration.",
25
+ )
26
+ parser.add_argument(
27
+ "-n", "--dry-run", action="store_true", dest="dry_run",
28
+ help="List what would be removed without deleting anything.",
29
+ )
30
+
31
+ def execute(self, args):
32
+ return pipeline.run_clean(output_dir=args.output,
33
+ include_renode=args.all,
34
+ dry_run=args.dry_run)
@@ -17,8 +17,8 @@ class RunCommand(Command):
17
17
  def execute(self, args):
18
18
  pipeline.apply_global_flags(args)
19
19
 
20
- # Convenience fast path: a snapshot + pinned version needs no .hector.yaml.
21
- if args.snapshot and args.renode_version:
20
+ # Convenience fast path: a snapshot + pinned version/image needs no .hector.yaml.
21
+ if args.snapshot and (args.renode_version or args.renode_image):
22
22
  pipeline.snapshot_fastpath(args)
23
23
  return 0
24
24
 
@@ -67,7 +67,7 @@ class Registry:
67
67
  # ==========================================================================
68
68
  # CONSTANTS
69
69
  # ==========================================================================
70
- TOOL_VERSION = "0.3.0"
70
+ TOOL_VERSION = "0.4.0"
71
71
  SUPPORTED_SCHEMA_VERSIONS = {"0.1"}
72
72
 
73
73
  RENODE_REPO_URL = "https://github.com/renode/renode.git"
@@ -11,6 +11,7 @@ Phases per job:
11
11
 
12
12
  import copy
13
13
  import os
14
+ import re
14
15
  import shlex
15
16
  import sys
16
17
  from dataclasses import dataclass, field
@@ -106,15 +107,35 @@ def load_config_or_exit():
106
107
  return config
107
108
 
108
109
 
110
+ _VERSION_TAG_RE = re.compile(r"^v?\d+\.\d+\.\d+$")
111
+
112
+
109
113
  def resolve_renode_image(config, args):
110
- """Pin the Renode version from --renode-version or the config; return
111
- (git_tag, docker_image)."""
112
- renode_version = getattr(args, "renode_version", None) or config.get("renode_version")
113
- if not renode_version:
114
- raise ValueError("Top-level 'renode_version' is required (e.g. '1.16.1'), "
115
- "or pass --renode-version on the command line.")
116
- normalized = str(renode_version).lstrip("v")
117
- return f"v{normalized}", f"antmicro/renode:{normalized}"
114
+ """Pick the Renode Docker image and the git tag of the matching source; return
115
+ (git_tag, docker_image).
116
+
117
+ The image is either an explicit `renode_image` (any image, including a self-built
118
+ one) or `antmicro/renode:<renode_version>`. CLI flags beat the config, and a flag
119
+ of either kind replaces both config fields — so `--renode-version` still wins over
120
+ a `renode_image:` in the file. The git tag (only used to clone the Renode source)
121
+ comes from the version, or from the image tag when it looks like one; it is None
122
+ when neither tells us, and only matters if the config needs the source."""
123
+ flag_version = getattr(args, "renode_version", None)
124
+ flag_image = getattr(args, "renode_image", None)
125
+ if flag_version or flag_image:
126
+ version, image = flag_version, flag_image
127
+ else:
128
+ version, image = config.get("renode_version"), config.get("renode_image")
129
+ if not version and not image:
130
+ raise ValueError("Top-level 'renode_version' (e.g. '1.16.1') or 'renode_image' "
131
+ "is required, or pass --renode-version / --renode-image on the "
132
+ "command line.")
133
+ normalized = str(version).lstrip("v") if version else None
134
+ image = str(image) if image else f"antmicro/renode:{normalized}"
135
+ if not normalized:
136
+ tag = image.rsplit("/", 1)[-1].partition(":")[2]
137
+ normalized = tag.lstrip("v") if _VERSION_TAG_RE.match(tag) else None
138
+ return (f"v{normalized}" if normalized else None), image
118
139
 
119
140
 
120
141
  def _resolve_set_overrides(args):
@@ -149,6 +170,10 @@ def build_job_config(action, config, args, *, test_mode=False):
149
170
  # Clone Renode only when the config actually needs the source; otherwise skip it
150
171
  # entirely (a ~200 MB clone) and leave the framework vars empty.
151
172
  if _needs_renode_source(config):
173
+ if not git_tag:
174
+ raise ValueError(f"This config needs the Renode source, but the version of "
175
+ f"'{docker_image}' can't be inferred from its tag — also set "
176
+ "'renode_version' (or --renode-version) to pick the checkout.")
152
177
  renode_dir, integration_dir = prepare_dependencies(
153
178
  git_tag,
154
179
  renode_dir=getattr(args, "renode_dir", None),
@@ -210,13 +235,12 @@ def execute_jobs(cfg, args):
210
235
 
211
236
 
212
237
  def snapshot_fastpath(args):
213
- """`run --snapshot --renode-version` with no .hector.yaml: write a snapshot
214
- loader resc and boot it directly, skipping config/dependency machinery."""
238
+ """`run --snapshot --renode-version|--renode-image` with no .hector.yaml: write a
239
+ snapshot loader resc and boot it directly, skipping config/dependency machinery."""
215
240
  if not os.path.isfile(args.snapshot):
216
241
  print(f"[ERROR] Snapshot not found: {args.snapshot}", file=sys.stderr)
217
242
  sys.exit(1)
218
- normalized = args.renode_version.lstrip("v")
219
- docker_image = f"antmicro/renode:{normalized}"
243
+ _, docker_image = resolve_renode_image({}, args)
220
244
  resc_root = os.path.join(ARTIFACTS_DIR, "resc")
221
245
  os.makedirs(resc_root, exist_ok=True)
222
246
  # No .hector.yaml here, so debug machine names come straight from --debug.
@@ -256,6 +280,81 @@ def _run_validate():
256
280
  sys.exit(0)
257
281
 
258
282
 
283
+ # ---------------------------------------------------------------------------
284
+ # clean
285
+ # ---------------------------------------------------------------------------
286
+
287
+ # The Renode source / verilator-integration checkouts hector clones on demand. They
288
+ # live under .hector/ but are expensive to re-fetch (~200 MB), so `clean` keeps them
289
+ # unless --all is given.
290
+ _RENODE_CHECKOUTS = ("renode", "renode-verilator-integration")
291
+
292
+
293
+ def clean_targets(output_dir="results", include_renode=False):
294
+ """The generated paths `hector clean` would remove, filtered to those that exist.
295
+
296
+ Always: the cheap build artifacts (.hector/resc, .hector/repl, .hector/tests) and
297
+ the results/output directory. Only with include_renode: the whole .hector/ (which
298
+ also takes the expensive Renode checkouts with it). Paths are relative to the cwd."""
299
+ if include_renode:
300
+ targets = [ARTIFACTS_DIR, output_dir]
301
+ else:
302
+ targets = [os.path.join(ARTIFACTS_DIR, d) for d in ("resc", "repl", "tests")]
303
+ targets.append(output_dir)
304
+ # De-dupe while preserving order, keep only what's actually there.
305
+ seen, existing = set(), []
306
+ for p in targets:
307
+ if p not in seen and os.path.exists(p):
308
+ seen.add(p)
309
+ existing.append(p)
310
+ return existing
311
+
312
+
313
+ def _is_within_cwd(path):
314
+ """True if `path` resolves inside the project root — the guard that stops `clean`
315
+ from ever deleting something outside the workspace (e.g. an absolute --output)."""
316
+ root = os.path.realpath(os.getcwd())
317
+ real = os.path.realpath(path)
318
+ return real == root or real.startswith(root + os.sep)
319
+
320
+
321
+ def run_clean(output_dir="results", include_renode=False, dry_run=False):
322
+ """Remove hector's generated artifacts. Returns a process exit code."""
323
+ import shutil
324
+
325
+ targets = clean_targets(output_dir, include_renode)
326
+ if not targets:
327
+ print("[CLEAN] Nothing to remove — the workspace is already clean.")
328
+ return 0
329
+
330
+ removed = 0
331
+ for path in targets:
332
+ if not _is_within_cwd(path):
333
+ print(f"[CLEAN] SKIP {path} — outside the project directory; refusing to remove it.")
334
+ continue
335
+ kind = "dir " if os.path.isdir(path) else "file"
336
+ if dry_run:
337
+ print(f"[CLEAN] would remove {kind} {path}")
338
+ continue
339
+ if os.path.isdir(path):
340
+ shutil.rmtree(path, ignore_errors=True)
341
+ else:
342
+ os.remove(path)
343
+ print(f"[CLEAN] removed {kind} {path}")
344
+ removed += 1
345
+
346
+ if dry_run:
347
+ print("[CLEAN] dry run — nothing was deleted.")
348
+ elif not include_renode and os.path.isdir(ARTIFACTS_DIR):
349
+ # We kept a Renode checkout; tell the user how to drop it too.
350
+ kept = [d for d in _RENODE_CHECKOUTS
351
+ if os.path.isdir(os.path.join(ARTIFACTS_DIR, d))]
352
+ if kept:
353
+ print(f"[CLEAN] kept cached Renode checkout(s) in {ARTIFACTS_DIR}/ "
354
+ f"({', '.join(kept)}) — use 'hector clean --all' to remove them too.")
355
+ return 0
356
+
357
+
259
358
  # ---------------------------------------------------------------------------
260
359
  # Helpers
261
360
  # ---------------------------------------------------------------------------
@@ -46,7 +46,7 @@ def scaffold_init(target=".hector.yaml"):
46
46
  name .hector.yaml; override via -f/--file)."""
47
47
  if os.path.exists(target):
48
48
  print(f"[INIT] {target} already exists — not overwriting.")
49
- print(f"[INIT] Delete or rename it, then run 'hector init' again.")
49
+ print("[INIT] Delete or rename it, then run 'hector init' again.")
50
50
  sys.exit(1)
51
51
 
52
52
  print("Hector project initialisation")
@@ -10,7 +10,6 @@ Usage:
10
10
  for w in warnings: print(f" WARNING {w.path}: {w.message}")
11
11
  """
12
12
 
13
- import re
14
13
  from dataclasses import dataclass
15
14
  from typing import List, Tuple
16
15
 
@@ -20,12 +19,19 @@ from .mappings import MAPPING_BACKENDS
20
19
  from .modules import MODULE_KINDS
21
20
  from .runners import TEST_RUNNERS
22
21
 
22
+ # CI trigger / run-control keys. The whole .hector.yaml is a single pipeline (there is
23
+ # no nested `ci:` block). These belong entirely to the CI server — the CLI's run/test/
24
+ # export never read them and it must not act on them. They are listed here only so the
25
+ # CLI accepts them silently instead of flagging them; the CI backend is their sole
26
+ # validator (see ci/backend/hectorci/config.py).
27
+ _TRIGGER_TOP_KEYS = {"when", "cron", "reporters", "jobs", "fail_fast", "timeout"}
28
+
23
29
  # Top-level keys defined by this schema version; anything else is flagged.
24
30
  _KNOWN_TOP_KEYS = {
25
- "version", "renode_version", "arguments", "matrix",
31
+ "version", "renode_version", "renode_image", "arguments", "matrix",
26
32
  "modules", "hubs", "machines", "connections", "mappings", "build", "tests", "quantum",
27
- "artifacts", "ci",
28
- }
33
+ "artifacts",
34
+ } | _TRIGGER_TOP_KEYS
29
35
 
30
36
  # Keys renamed in this schema version → new name (for a helpful migration hint).
31
37
  _RENAMED_TOP_KEYS = {"nodes": "machines", "setup": "build", "prepare": "build"}
@@ -33,12 +39,6 @@ _RENAMED_TOP_KEYS = {"nodes": "machines", "setup": "build", "prepare": "build"}
33
39
  # Test types renamed this schema version → current name (old configs still run).
34
40
  _RENAMED_TEST_TYPES = {"bash": "shell", "docker": "shell"}
35
41
 
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
42
  _KNOWN_NODE_KEYS = {
43
43
  "backend", "platform", "repl", "resc", "firmware",
44
44
  "peripherals", "init", "connections", "mappings", "commands",
@@ -80,12 +80,16 @@ def validate_config(config) -> Tuple[List[Issue], List[Issue]]:
80
80
  f"({', '.join(sorted(SUPPORTED_SCHEMA_VERSIONS))}). "
81
81
  "Some fields may not be recognised.")
82
82
 
83
- # ---- renode_version (required) ----
83
+ # ---- renode_version / renode_image (one of them required) ----
84
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)):
85
+ ri = config.get("renode_image")
86
+ if not rv and not ri:
87
+ err("renode_version", "Required field missing (e.g. '1.16.1') — or set "
88
+ "'renode_image' to use a specific Docker image.")
89
+ if rv and not isinstance(rv, (str, int, float)):
88
90
  err("renode_version", f"Must be a string like '1.16.1', got {type(rv).__name__}.")
91
+ if ri and not isinstance(ri, str):
92
+ err("renode_image", f"Must be a string like 'my-renode:dev', got {type(ri).__name__}.")
89
93
 
90
94
  # ---- unknown / renamed top-level keys ----
91
95
  for k in config:
@@ -291,8 +295,8 @@ def validate_config(config) -> Tuple[List[Issue], List[Issue]]:
291
295
  except (TypeError, ValueError):
292
296
  err("quantum", f"Must be a positive number (seconds), got '{quantum}'.")
293
297
 
294
- # ---- ci (CI pipelines) ----
295
- _validate_ci(config.get("ci"), err, warn)
298
+ # ---- CI triggers (when/cron/…) are accepted but never validated or acted on
299
+ # here; they belong to the CI server. See _TRIGGER_TOP_KEYS. ----
296
300
 
297
301
  return errors, warnings
298
302
 
@@ -328,71 +332,6 @@ def _validate_shell_steps(item, p, err, warn):
328
332
  warn(p, "Both 'file' and 'script' — 'file' takes precedence.")
329
333
 
330
334
 
331
- def _validate_ci(ci_cfg, err, warn):
332
- """Validate the top-level `ci:` block (pipeline-name -> CI settings)."""
333
- if ci_cfg is None:
334
- return
335
- if not isinstance(ci_cfg, dict):
336
- err("ci", f"Must be a mapping of pipeline-name: settings, got {type(ci_cfg).__name__}.")
337
- return
338
- for name, pipe in ci_cfg.items():
339
- p = f"ci.{name}"
340
- if not isinstance(pipe, dict):
341
- err(p, f"Pipeline must be a mapping, got {type(pipe).__name__}."); continue
342
- for k in pipe:
343
- if k not in _CI_PIPELINE_KEYS:
344
- warn(f"{p}.{k}", f"Unknown pipeline key '{k}'. "
345
- f"Known keys: {', '.join(sorted(_CI_PIPELINE_KEYS))}.")
346
-
347
- # when: { branch, event, path }
348
- when = pipe.get("when")
349
- events = []
350
- if when is not None:
351
- if not isinstance(when, dict):
352
- err(f"{p}.when", f"Must be a mapping, got {type(when).__name__}.")
353
- else:
354
- for k in when:
355
- if k not in _CI_WHEN_KEYS:
356
- warn(f"{p}.when.{k}", f"Unknown 'when' key '{k}'.")
357
- raw_event = when.get("event")
358
- if raw_event is None:
359
- events = []
360
- elif not isinstance(raw_event, list):
361
- err(f"{p}.when.event", f"Must be a list, got {type(raw_event).__name__}.")
362
- events = []
363
- else:
364
- events = raw_event
365
- for ev in events:
366
- if not isinstance(ev, str) or ev not in _CI_EVENTS:
367
- err(f"{p}.when.event", f"Unknown event {ev!r}. "
368
- f"Allowed: {', '.join(sorted(_CI_EVENTS))}.")
369
-
370
- # jobs / fail_fast
371
- jobs = pipe.get("jobs")
372
- if jobs is not None and (not isinstance(jobs, int) or isinstance(jobs, bool) or jobs < 1):
373
- err(f"{p}.jobs", f"Must be a positive integer, got {jobs!r}.")
374
- if pipe.get("fail_fast") is True and (jobs or 1) != 1:
375
- err(f"{p}.fail_fast", "Only valid with jobs: 1.")
376
-
377
- # cron (required iff event includes 'cron')
378
- has_cron_event = "cron" in events
379
- if has_cron_event and not isinstance(pipe.get("cron"), str):
380
- err(f"{p}.cron", "A cron expression is required when event includes 'cron'.")
381
- if isinstance(pipe.get("cron"), str) and not has_cron_event:
382
- warn(f"{p}.cron", "cron is set but when.event does not include 'cron'.")
383
-
384
- # timeout / reporters / arguments
385
- timeout = pipe.get("timeout")
386
- if timeout is not None and (not isinstance(timeout, str) or not _CI_TIMEOUT_RE.match(timeout)):
387
- err(f"{p}.timeout", "Must look like '30m', '90s' or '2h'.")
388
- reporters = pipe.get("reporters")
389
- if reporters is not None and not isinstance(reporters, list):
390
- err(f"{p}.reporters", f"Must be a list, got {type(reporters).__name__}.")
391
- arguments = pipe.get("arguments")
392
- if arguments is not None and not isinstance(arguments, dict):
393
- err(f"{p}.arguments", f"Must be a mapping of name: value, got {type(arguments).__name__}.")
394
-
395
-
396
335
  def _validate_connection_lines(section, path, err, warn):
397
336
  for line in as_lines(section):
398
337
  if "->" not in line and "<->" not in line: