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/core.py ADDED
@@ -0,0 +1,259 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """
3
+ Core plumbing: the extension Registry, shared constants, host<->container path
4
+ translation, YAML/interpolation/matrix helpers, repl emitters, and the endpoint parser.
5
+
6
+ You rarely need to touch this file. The interesting, extensible parts live in:
7
+ hubs.py - add a cross-machine connection medium (uart, can, gpio, ...)
8
+ runners.py - add a test runner (bash, robot, renode-test, ...)
9
+ mappings.py - add a peripheral->host backend (file, ...)
10
+ modules.py - add a module build kind (renode-verilator, ...)
11
+ """
12
+
13
+ import itertools
14
+ import os
15
+ import re
16
+ import subprocess
17
+ from dataclasses import dataclass, field
18
+
19
+ import yaml
20
+
21
+
22
+ # ==========================================================================
23
+ # REGISTRY (the extension mechanism used across the codebase)
24
+ # ==========================================================================
25
+ class Registry:
26
+ """A named lookup table you extend with a decorator.
27
+
28
+ WIDGETS = Registry("widget")
29
+
30
+ @WIDGETS.register("foo")
31
+ class Foo: ...
32
+
33
+ WIDGETS.get("foo") # -> Foo
34
+ "foo" in WIDGETS # -> True
35
+ list(WIDGETS.keys()) # -> ["foo", ...]
36
+
37
+ Unknown keys raise a helpful error listing what *is* available, so a typo in
38
+ the YAML produces a clear message instead of a KeyError.
39
+ """
40
+ def __init__(self, name):
41
+ self.name = name
42
+ self._items = {}
43
+
44
+ def register(self, key):
45
+ def deco(obj):
46
+ if key in self._items:
47
+ raise ValueError(f"{self.name} '{key}' is already registered.")
48
+ self._items[key] = obj
49
+ return obj
50
+ return deco
51
+
52
+ def get(self, key):
53
+ if key not in self._items:
54
+ raise KeyError(
55
+ f"Unknown {self.name} '{key}'. Available: {', '.join(sorted(self._items)) or '(none)'}."
56
+ )
57
+ return self._items[key]
58
+
59
+ def __contains__(self, key):
60
+ return key in self._items
61
+
62
+ def keys(self):
63
+ return self._items.keys()
64
+
65
+
66
+ # ==========================================================================
67
+ # CONSTANTS
68
+ # ==========================================================================
69
+ TOOL_VERSION = "0.1.0"
70
+ SUPPORTED_SCHEMA_VERSIONS = {"0.1"}
71
+
72
+ RENODE_REPO_URL = "https://github.com/renode/renode.git"
73
+ INTEGRATION_REPO_URL = "https://github.com/antmicro/renode-verilator-integration.git"
74
+
75
+ ARTIFACTS_DIR = ".hector" # everything we generate/clone lives here (gitignore it)
76
+ WORKSPACE_MOUNT = os.getcwd() # cwd is bind-mounted at the same path inside the container
77
+ NO_DOCKER = False # when True, call renode/renode-test directly on host
78
+ EXTRA_MOUNTS = [] # dep dirs outside the workspace, bind-mounted 1:1 into every container
79
+ LIBOPENLIBM_NAME = "libopenlibm-Linux-x86_64.a"
80
+
81
+ DEFAULT_QUANTUM = "0.00001" # 10 us; applied when any hub is present
82
+
83
+
84
+ # ==========================================================================
85
+ # RUNTIME OPTIONS (execution-environment needs declared by components)
86
+ # ==========================================================================
87
+ @dataclass
88
+ class RuntimeOptions:
89
+ """Execution-environment requirements a component (mapping / hub / module) needs
90
+ from the Renode run. Components declare these at parse time; the pipeline merges
91
+ them across all components and the docker layer maps them to `docker run` flags at
92
+ execution time. Add fields here as new needs arise (ports, mounts, ...)."""
93
+ capabilities: set = field(default_factory=set) # -> docker --cap-add
94
+ devices: set = field(default_factory=set) # -> docker --device
95
+ root: bool = False # -> docker -u 0:0 (run as root)
96
+
97
+ def merge(self, other):
98
+ """Fold another RuntimeOptions (or None) into this one."""
99
+ if other:
100
+ self.capabilities |= other.capabilities
101
+ self.devices |= other.devices
102
+ self.root = self.root or other.root
103
+ return self
104
+
105
+
106
+ # ==========================================================================
107
+ # PATH TRANSLATION (host <-> container)
108
+ # ==========================================================================
109
+ def to_container_path(host_path):
110
+ abs_path = os.path.realpath(host_path)
111
+ root = os.path.realpath(os.getcwd())
112
+ if os.path.commonpath([abs_path, root]) != root:
113
+ raise ValueError(
114
+ f"Path '{host_path}' resolves outside the project root '{root}'. Everything used "
115
+ f"at runtime must live under the project folder (mounted at '{WORKSPACE_MOUNT}')."
116
+ )
117
+ return os.path.join(WORKSPACE_MOUNT, os.path.relpath(abs_path, root))
118
+
119
+
120
+ def register_dependency_mount(host_dir):
121
+ """Return the container path for a dependency checkout (Renode source / integration),
122
+ bind-mounting it 1:1 into every container if it lives OUTSIDE the workspace.
123
+
124
+ Dirs under the workspace are already covered by the workspace mount, so they map via
125
+ to_container_path. An out-of-tree dir (e.g. a shared cache) is registered in
126
+ EXTRA_MOUNTS and used at its own absolute path inside the container."""
127
+ abs_dir = os.path.realpath(host_dir)
128
+ root = os.path.realpath(os.getcwd())
129
+ if os.path.commonpath([abs_dir, root]) == root:
130
+ return to_container_path(host_dir)
131
+ if abs_dir not in EXTRA_MOUNTS:
132
+ EXTRA_MOUNTS.append(abs_dir)
133
+ return abs_dir
134
+
135
+
136
+ def container_path_for(host_path):
137
+ """Container path for a host path that may live under an out-of-tree dependency mount
138
+ (EXTRA_MOUNTS, bind-mounted 1:1); otherwise the normal workspace mapping."""
139
+ abs_p = os.path.realpath(host_path)
140
+ for m in EXTRA_MOUNTS:
141
+ m_abs = os.path.realpath(m)
142
+ if abs_p == m_abs or abs_p.startswith(m_abs + os.sep):
143
+ return abs_p
144
+ return to_container_path(host_path)
145
+
146
+
147
+ def container_to_host(container_path):
148
+ if container_path != WORKSPACE_MOUNT and not container_path.startswith(WORKSPACE_MOUNT + os.sep):
149
+ raise ValueError(f"'{container_path}' is not under {WORKSPACE_MOUNT}.")
150
+ rel = os.path.relpath(container_path, WORKSPACE_MOUNT)
151
+ host = os.path.realpath(os.path.join(os.getcwd(), rel))
152
+ root = os.path.realpath(os.getcwd())
153
+ if os.path.commonpath([host, root]) != root:
154
+ raise ValueError(f"'{container_path}' maps outside the project root.")
155
+ return host
156
+
157
+
158
+ def split_host_container(path):
159
+ if path == WORKSPACE_MOUNT or path.startswith(WORKSPACE_MOUNT + os.sep):
160
+ return container_to_host(path), path
161
+ host = os.path.realpath(path)
162
+ return host, to_container_path(host)
163
+
164
+
165
+ # ==========================================================================
166
+ # YAML / TEXT HELPERS
167
+ # ==========================================================================
168
+ def load_yaml(filepath):
169
+ with open(filepath, "r") as f:
170
+ return yaml.safe_load(f)
171
+
172
+
173
+ def run_shell(cmd, cwd=None, shell="/bin/bash"):
174
+ subprocess.run(cmd, shell=True, check=True, executable=shell, cwd=cwd)
175
+
176
+
177
+ def as_lines(section):
178
+ """Normalize a YAML block-string or list into meaningful lines (drop blanks and
179
+ full-line '#' comments)."""
180
+ if not section:
181
+ return []
182
+ raw = section.splitlines() if isinstance(section, str) else list(section)
183
+ return [s.strip() for s in (str(x) for x in raw)
184
+ if s.strip() and not s.strip().startswith("#")]
185
+
186
+
187
+ # ==========================================================================
188
+ # MATRIX & INTERPOLATION
189
+ # ==========================================================================
190
+ def expand_matrix(matrix_block):
191
+ if not matrix_block or "variables" not in matrix_block:
192
+ return [{}]
193
+ variables = matrix_block["variables"]
194
+ excludes = matrix_block.get("exclude", [])
195
+ all_jobs = [dict(zip(variables.keys(), combo))
196
+ for combo in itertools.product(*variables.values())]
197
+ return [job for job in all_jobs
198
+ if not any(all(job.get(k) == v for k, v in rule.items()) for rule in excludes)]
199
+
200
+
201
+ def resolve_arguments(arguments, overrides=None):
202
+ """name -> value. Precedence: --set flag > env var > config default."""
203
+ overrides = overrides or {}
204
+ resolved = {}
205
+ for name, default in (arguments or {}).items():
206
+ if name in overrides:
207
+ resolved[name] = overrides[name]
208
+ source = "cli"
209
+ elif name in os.environ:
210
+ resolved[name] = os.environ[name]
211
+ source = "env"
212
+ else:
213
+ resolved[name] = default
214
+ source = "default"
215
+ print(f"[ARG] {name} = {resolved[name]} ({source})")
216
+ # --set can also introduce keys not declared in arguments:
217
+ for name, value in overrides.items():
218
+ if name not in resolved:
219
+ resolved[name] = value
220
+ print(f"[ARG] {name} = {value} (cli, undeclared)")
221
+ return resolved
222
+
223
+
224
+ def interpolate(data, context):
225
+ if isinstance(data, str):
226
+ return re.sub(r"\$\{([^}]+)\}",
227
+ lambda m: str(context.get(m.group(1), m.group(0))), data)
228
+ if isinstance(data, dict):
229
+ return {k: interpolate(v, context) for k, v in data.items()}
230
+ if isinstance(data, list):
231
+ return [interpolate(item, context) for item in data]
232
+ return data
233
+
234
+
235
+ # ==========================================================================
236
+ # ENDPOINT PARSER (connections + mappings)
237
+ # node.name -> kind='plain'
238
+ # node.name@pin -> kind='pin', pin=N (Renode-style '@' pin / line number)
239
+ # name -> kind='self' (bare; typically a hub reference)
240
+ # ==========================================================================
241
+ def parse_endpoint(token):
242
+ token = token.strip()
243
+ if "." not in token:
244
+ return {"node": token, "name": token, "kind": "self", "raw": token}
245
+ node, rest = token.split(".", 1)
246
+ ep = {"node": node, "raw": rest}
247
+ m = re.match(r"^([A-Za-z0-9_]+)@(0x[0-9A-Fa-f]+|\d+)$", rest)
248
+ if m:
249
+ ep.update(kind="pin", name=m.group(1), pin=int(m.group(2), 0))
250
+ return ep
251
+ if re.match(r"^[A-Za-z0-9_]+$", rest):
252
+ ep.update(kind="plain", name=rest)
253
+ return ep
254
+ # node.periph.signal (e.g. mcu.nvic.IRQ)
255
+ m2 = re.match(r"^([A-Za-z0-9_]+)\.([A-Za-z0-9_]+)$", rest)
256
+ if m2:
257
+ ep.update(kind="signal", periph=m2.group(1), name=m2.group(1), signal=m2.group(2))
258
+ return ep
259
+ raise ValueError(f"Could not parse endpoint '{token}'")
hector/dependencies.py ADDED
@@ -0,0 +1,41 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """Managed clones of Renode and the verilator integration, pinned to renode_version."""
3
+
4
+ import os
5
+
6
+ from .core import (ARTIFACTS_DIR, INTEGRATION_REPO_URL, RENODE_REPO_URL,
7
+ register_dependency_mount, run_shell)
8
+
9
+
10
+ def prepare_dependencies(git_tag, renode_dir=None, integration_dir=None):
11
+ """Ensure the Renode source and verilator-integration checkouts exist, returning
12
+ their paths. Each location defaults to .hector/ but can be overridden (e.g. a cached
13
+ or shared checkout): an existing directory is reused as-is (no clone), a missing one
14
+ is cloned into. Only called when the run actually needs the source (see callers)."""
15
+ os.makedirs(ARTIFACTS_DIR, exist_ok=True)
16
+ renode_dir = renode_dir or os.path.join(ARTIFACTS_DIR, "renode")
17
+ integration_dir = integration_dir or os.path.join(ARTIFACTS_DIR, "renode-verilator-integration")
18
+
19
+ if not os.path.isdir(renode_dir):
20
+ print(f"[DEPS] Cloning Renode @ {git_tag} → {renode_dir} ...")
21
+ run_shell(f'git clone --depth 1 --branch "{git_tag}" --recurse-submodules '
22
+ f'--shallow-submodules "{RENODE_REPO_URL}" "{renode_dir}"')
23
+ else:
24
+ print(f"[DEPS] Renode present at {renode_dir} (delete to refresh).")
25
+
26
+ if not os.path.isdir(integration_dir):
27
+ print(f"[DEPS] Cloning renode-verilator-integration → {integration_dir} ...")
28
+ run_shell(f'git clone --recurse-submodules "{INTEGRATION_REPO_URL}" "{integration_dir}"')
29
+ else:
30
+ print(f"[DEPS] Integration present at {integration_dir} (delete to refresh).")
31
+
32
+ return renode_dir, integration_dir
33
+
34
+
35
+ def framework_vars(renode_dir, integration_dir):
36
+ """Interpolation variables exposing the checkouts as container paths, bind-mounting
37
+ any that live outside the workspace so containers reach them at the same path."""
38
+ return {
39
+ "RENODE_DIR": register_dependency_mount(renode_dir),
40
+ "INTEGRATION_DIR": register_dependency_mount(integration_dir),
41
+ }
hector/docker.py ADDED
@@ -0,0 +1,191 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """
3
+ Thin wrappers around `docker run` for the things we launch in the container:
4
+ module builds, interactive Renode simulation, renode-test runs, and the
5
+ interactive robot REPL. Centralising them here keeps the mount/flag
6
+ boilerplate in one place.
7
+ """
8
+
9
+ import os
10
+ import subprocess
11
+
12
+ from . import core
13
+ from .core import to_container_path
14
+
15
+
16
+ def _xdisplay_args():
17
+ xauth = os.environ.get("XAUTHORITY", f"{os.environ.get('HOME')}/.Xauthority")
18
+ display = os.environ.get("DISPLAY", ":0")
19
+ # Pin XAUTHORITY explicitly so X auth still resolves when the container runs as the
20
+ # host UID (see _user_flags) rather than the image's baked-in user with HOME set.
21
+ return ["-e", f"DISPLAY={display}", "-e", "XAUTHORITY=/home/developer/.Xauthority",
22
+ "-v", f"{xauth}:/home/developer/.Xauthority"]
23
+
24
+
25
+ def _user_flags():
26
+ """Run the container as the host user, so files it writes into the bind-mounted
27
+ workspace are owned by them on the host — not root. Used by the shell/build and
28
+ renode-test containers.
29
+
30
+ Placed BEFORE _runtime_flags at the renode call sites: docker honours the last
31
+ `-u`, so a mapping that legitimately needs root (e.g. a tap device, via
32
+ runtime.root) still wins. The module-build container (run_build) is the one
33
+ exception — it needs root for apt-get and chowns its artifacts back itself."""
34
+ return ["-u", f"{os.getuid()}:{os.getgid()}"]
35
+
36
+
37
+ def _workspace_mount():
38
+ return ["-v", f"{os.getcwd()}:{core.WORKSPACE_MOUNT}", "-w", core.WORKSPACE_MOUNT]
39
+
40
+
41
+ def _extra_mounts():
42
+ """Bind-mount out-of-tree dependency dirs (e.g. a shared Renode source cache) 1:1, so
43
+ containers reach them at the same path they have on the host. Empty for the common case."""
44
+ args = []
45
+ for d in core.EXTRA_MOUNTS:
46
+ args += ["-v", f"{d}:{d}"]
47
+ return args
48
+
49
+
50
+ def _runtime_flags(runtime):
51
+ """Map aggregated RuntimeOptions (capabilities/devices/root, declared by mappings,
52
+ hubs, modules) to `docker run` flags.
53
+
54
+ A requested --device is dropped if absent on the host so `docker run` never fails;
55
+ and if devices were requested but none exist, no flags are applied at all (e.g. a
56
+ tap on a host without /dev/net/tun runs as the normal user in dummy mode rather than
57
+ needlessly as root)."""
58
+ if not runtime:
59
+ return []
60
+ present = [d for d in sorted(runtime.devices) if os.path.exists(d)]
61
+ if runtime.devices and not present:
62
+ return []
63
+ flags = []
64
+ if runtime.root:
65
+ flags += ["-u", "0:0"]
66
+ for cap in sorted(runtime.capabilities):
67
+ flags += ["--cap-add", cap]
68
+ for dev in present:
69
+ flags += ["--device", dev]
70
+ return flags
71
+
72
+
73
+ def run_build(script, image):
74
+ """Run a build script in the container as root (for apt-get + cmake)."""
75
+ if core.NO_DOCKER:
76
+ subprocess.run(["bash", "-c", script], check=True)
77
+ return
78
+ subprocess.run(
79
+ ["docker", "run", "--rm", "-u", "0:0",
80
+ *_workspace_mount(), *_extra_mounts(), image, "bash", "-c", script],
81
+ check=True,
82
+ )
83
+
84
+
85
+ def resolve_shell_image(item, default_image, label="shell"):
86
+ """Decide which image a shell step/test runs in, and log the decision.
87
+
88
+ - explicit ``image:`` → use it
89
+ - none → ``default_image`` (the run's renode image), logged
90
+ so the user sees the fallback
91
+ - under ``--no-docker`` → the image is irrelevant (the step runs on the host);
92
+ warn if one was set explicitly, since it's ignored
93
+
94
+ Returns the image string. Under --no-docker run_in_container ignores it, so the value
95
+ only matters for display there."""
96
+ image = item.get("image")
97
+ name = item.get("name") or label
98
+ if core.NO_DOCKER:
99
+ if image:
100
+ print(f"[WARN] --no-docker: image '{image}' for '{name}' is ignored; "
101
+ "running on the host.")
102
+ return image or default_image
103
+ if not image:
104
+ print(f"[INFO] '{name}': no image set; using the default image '{default_image}'.")
105
+ return image or default_image
106
+
107
+
108
+ def run_in_container(script, image, live=False):
109
+ """Run a shell script with the project bind-mounted at the workspace path, returning
110
+ (output, passed); streams when live=True. `set -ex` makes commands echo and the run
111
+ fail on the first error.
112
+
113
+ Backs the `shell` test type and the `build:` steps. Normally a one-off container of
114
+ `image`; under --no-docker the script runs directly on the host (the image is moot)."""
115
+ if core.NO_DOCKER:
116
+ cmd = ["bash", "-c", f"set -ex\n{script}"]
117
+ else:
118
+ cmd = ["docker", "run", "--rm", *_workspace_mount(), *_extra_mounts(),
119
+ *_user_flags(), image, "sh", "-c", f"set -ex\n{script}"]
120
+ if live:
121
+ proc = subprocess.Popen(cmd, stdout=subprocess.PIPE,
122
+ stderr=subprocess.STDOUT, text=True)
123
+ lines = []
124
+ for line in proc.stdout:
125
+ print(line, end="", flush=True)
126
+ lines.append(line)
127
+ proc.wait()
128
+ return "".join(lines), proc.returncode == 0
129
+ proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
130
+ if proc.stdout:
131
+ print(proc.stdout, end="")
132
+ return proc.stdout, proc.returncode == 0
133
+
134
+
135
+ def build_renode_argv(resc_filename, image, ports=None, extra_args=None, runtime=None):
136
+ """Build the exact argv that would run an emulation resc — the local `renode`
137
+ invocation under --no-docker, otherwise the full `docker run ... renode` command.
138
+
139
+ Returning the command (instead of running it) is what lets the `export` command
140
+ print it verbatim and keeps run_renode a one-liner around this builder.
141
+ runtime: aggregated RuntimeOptions whose docker flags (caps/devices/root) are added."""
142
+ if core.NO_DOCKER:
143
+ return ["renode", resc_filename] + (extra_args or [])
144
+ cmd = ["docker", "run", "-it", "--rm", *_xdisplay_args(), "--net=host",
145
+ *_workspace_mount(), *_extra_mounts(), *_user_flags(), *_runtime_flags(runtime)]
146
+ for port in (ports or []):
147
+ cmd += ["-p", f"{port}:{port}"]
148
+ cmd += [image, "renode", resc_filename] + (extra_args or [])
149
+ return cmd
150
+
151
+
152
+ def run_renode(resc_filename, image, ports=None, extra_args=None, output_dir="results", runtime=None):
153
+ """Run an emulation resc interactively. In debug mode, expose GDB ports.
154
+ runtime: aggregated RuntimeOptions whose docker flags (caps/devices/root) are added."""
155
+ if not core.NO_DOCKER:
156
+ os.makedirs(os.path.join(os.getcwd(), output_dir), exist_ok=True)
157
+ subprocess.run(build_renode_argv(resc_filename, image, ports, extra_args, runtime))
158
+
159
+
160
+ def run_renode_test(robot_file_host, image, results_dir_host,
161
+ resc_host_path=None, extra_args=None, interactive=False, runtime=None):
162
+ """Run a .robot file with renode-test in the container. Returns True on pass.
163
+
164
+ resc_host_path: passed as --variable RESC:<path> so .robot files can load
165
+ the generated emulation script via ${RESC}.
166
+ extra_args: optional list of additional flags forwarded verbatim to renode-test.
167
+ interactive: attach stdin/tty and add --net=host (useful with Pause Execution
168
+ or when TCP ports need to be reachable from the host during the test).
169
+ """
170
+ os.makedirs(results_dir_host, exist_ok=True)
171
+ if core.NO_DOCKER:
172
+ cmd = ["renode-test", robot_file_host, "-r", results_dir_host]
173
+ if resc_host_path:
174
+ cmd += ["--variable", f"RESC:{resc_host_path}"]
175
+ if extra_args:
176
+ cmd += extra_args
177
+ result = subprocess.run(cmd)
178
+ return result.returncode == 0
179
+ flags = ["-it"] if interactive else []
180
+ net = ["--net=host"] if interactive else []
181
+ display = _xdisplay_args() if interactive else []
182
+ cmd = ["docker", "run", "--rm", *flags, *net, *display,
183
+ *_workspace_mount(), *_extra_mounts(), *_user_flags(), *_runtime_flags(runtime),
184
+ image, "renode-test", to_container_path(robot_file_host),
185
+ "-r", to_container_path(results_dir_host)]
186
+ if resc_host_path:
187
+ cmd += ["--variable", f"RESC:{to_container_path(resc_host_path)}"]
188
+ if extra_args:
189
+ cmd += extra_args
190
+ result = subprocess.run(cmd)
191
+ return result.returncode == 0