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/reporters.py ADDED
@@ -0,0 +1,111 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """
3
+ Pluggable test-result reporters.
4
+
5
+ Register a custom reporter:
6
+ from hector.reporters import REPORTERS
7
+
8
+ @REPORTERS.register("csv")
9
+ def csv_reporter(results, output_dir):
10
+ import csv, os
11
+ with open(os.path.join(output_dir, "results.csv"), "w") as f:
12
+ w = csv.DictWriter(f, fieldnames=["name","type","passed","duration"])
13
+ w.writeheader()
14
+ for r in results:
15
+ w.writerow({"name": r.name, "type": r.type,
16
+ "passed": r.passed, "duration": r.duration})
17
+ """
18
+
19
+ import json
20
+ import os
21
+ import xml.etree.ElementTree as ET
22
+ from dataclasses import dataclass, field
23
+
24
+ from .core import Registry
25
+
26
+ REPORTERS = Registry("reporter")
27
+
28
+
29
+ @dataclass
30
+ class StepResult:
31
+ """Outcome of a single test step, passed to every reporter after the run."""
32
+ name: str
33
+ type: str # "robot", "shell", "build", …
34
+ passed: bool
35
+ duration: float = 0.0
36
+ commands: list = field(default_factory=list) # script lines / commands run
37
+ stdout: str = "" # captured stdout (bash) or trimmed log
38
+ stderr: str = "" # captured stderr
39
+ artifacts: dict = field(default_factory=dict) # {label: abs_path}
40
+
41
+
42
+ def emit(results, output_dir, reporter_names):
43
+ """Call each named reporter with the full result list."""
44
+ os.makedirs(output_dir, exist_ok=True)
45
+ for name in reporter_names:
46
+ fn = REPORTERS.get(name)
47
+ fn(results, output_dir)
48
+
49
+
50
+ @REPORTERS.register("junit")
51
+ def junit_reporter(results, output_dir):
52
+ """JUnit XML — compatible with GitLab CI, GitHub Actions, and Jenkins."""
53
+ failures = sum(1 for r in results if not r.passed)
54
+ suite = ET.Element(
55
+ "testsuite",
56
+ name="hector",
57
+ tests=str(len(results)),
58
+ failures=str(failures),
59
+ time=f"{sum(r.duration for r in results):.3f}",
60
+ )
61
+ for r in results:
62
+ tc = ET.SubElement(
63
+ suite, "testcase",
64
+ name=r.name, classname=r.type,
65
+ time=f"{r.duration:.3f}",
66
+ )
67
+ out = "\n".join(filter(None, [r.stdout.strip(), r.stderr.strip()]))
68
+ parts = []
69
+ if r.commands:
70
+ parts.append("Commands:\n" + "\n".join(f" {c}" for c in r.commands))
71
+ if out:
72
+ parts.append("Output:\n" + out)
73
+ if parts:
74
+ ET.SubElement(tc, "system-out").text = "\n\n".join(parts)
75
+ if not r.passed:
76
+ ET.SubElement(tc, "failure", message="Test failed").text = (
77
+ r.stderr or r.stdout or ""
78
+ ).strip()
79
+
80
+ tree = ET.ElementTree(suite)
81
+ ET.indent(tree, space=" ")
82
+ path = os.path.join(output_dir, "junit.xml")
83
+ tree.write(path, encoding="unicode", xml_declaration=True)
84
+ print(f"[REPORT] junit → {path}")
85
+
86
+
87
+ @REPORTERS.register("json")
88
+ def json_reporter(results, output_dir):
89
+ """Machine-readable manifest for a results dashboard / CI. One entry per test,
90
+ each carrying its status, timing, commands, and links to its detail artifacts
91
+ (report.html / log.html / robot_output.xml). Paths are relative to the cwd."""
92
+ entries = []
93
+ for r in results:
94
+ entries.append({
95
+ "name": r.name,
96
+ "type": r.type,
97
+ "status": "passed" if r.passed else "failed",
98
+ "duration": round(r.duration, 3),
99
+ "commands": r.commands,
100
+ "artifacts": {k: os.path.relpath(v) for k, v in r.artifacts.items()},
101
+ })
102
+ manifest = {
103
+ "tests": len(results),
104
+ "failures": sum(1 for r in results if not r.passed),
105
+ "passed": all(r.passed for r in results),
106
+ "results": entries,
107
+ }
108
+ path = os.path.join(output_dir, "manifest.json")
109
+ with open(path, "w") as f:
110
+ json.dump(manifest, f, indent=2)
111
+ print(f"[REPORT] json → {path}")
hector/runners.py ADDED
@@ -0,0 +1,380 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """
3
+ TEST_RUNNERS — per-type test execution, dispatched by run_tests().
4
+
5
+ Each runner has the signature:
6
+ (test: dict, steps: list, ctx: RunnerContext, index: int) -> list[StepResult]
7
+
8
+ Register a custom type:
9
+ @TEST_RUNNERS.register("pytest")
10
+ def run_pytest(test, steps, ctx, index):
11
+ ...
12
+ return [StepResult(name=..., type="pytest", passed=..., stdout=...)]
13
+ """
14
+
15
+ import html as _html
16
+ import os
17
+ import re
18
+ import shlex
19
+ import time
20
+ from dataclasses import dataclass, field
21
+
22
+ from . import core as _core
23
+ from .core import ARTIFACTS_DIR, Registry, to_container_path
24
+ from .docker import resolve_shell_image, run_in_container, run_renode_test
25
+ from .reporters import StepResult
26
+
27
+ TEST_RUNNERS = Registry("test runner")
28
+
29
+
30
+ @dataclass
31
+ class RunnerContext:
32
+ resc_host_path: str # generated emulation resc
33
+ job_index: int # 1-based
34
+ image: str # antmicro/renode:<version>
35
+ reporters: list = field(default_factory=lambda: ["junit"])
36
+ results_dir: str = "results"
37
+ renode_test_args: list = field(default_factory=list)
38
+ live: bool = False
39
+ snapshot: str = ""
40
+ runtime: object = None # RuntimeOptions: docker caps/devices/root for the run
41
+
42
+
43
+ # ── helpers ───────────────────────────────────────────────────────────────────
44
+
45
+ def _parse_extra_args(obj):
46
+ raw = obj.get("args")
47
+ if not raw:
48
+ return []
49
+ return raw if isinstance(raw, list) else shlex.split(str(raw))
50
+
51
+
52
+ def _normalize_steps(test):
53
+ """Backward compat: test with top-level script:/file: treated as a single step."""
54
+ if "steps" in test:
55
+ return test["steps"]
56
+ return [{"name": test.get("name", ""), "script": test.get("script", ""), "file": test.get("file")}]
57
+
58
+
59
+ def _test_results_dir(base, test_index, name):
60
+ slug = re.sub(r"[^\w-]", "_", name)[:40]
61
+ return os.path.join(base, f"test_{test_index}_{slug}")
62
+
63
+
64
+ def _render_output_pre(output):
65
+ """Escape captured output; highlight `set -x` command-trace lines (starting '+')."""
66
+ out_lines = []
67
+ for line in output.splitlines():
68
+ esc = _html.escape(line)
69
+ out_lines.append(f'<span class="cmd-trace">{esc}</span>'
70
+ if line.startswith("+") else esc)
71
+ return "<br>".join(out_lines) or "(no output)"
72
+
73
+
74
+ def _write_shell_html(test_dir, test_name, step_records, passed, duration):
75
+ """Write results/test_N_<name>/report.html for a whole shell test.
76
+
77
+ Mirrors robot's per-test layout (one report at the test directory level), with
78
+ one section per step. `step_records` is a list of
79
+ {name, passed, duration, output} dicts.
80
+ """
81
+ os.makedirs(test_dir, exist_ok=True)
82
+ status_cls = "pass" if passed else "fail"
83
+ status_text = "PASS" if passed else "FAIL"
84
+
85
+ sections = []
86
+ for rec in step_records:
87
+ s_cls = "pass" if rec["passed"] else "fail"
88
+ s_txt = "PASS" if rec["passed"] else "FAIL"
89
+ sections.append(
90
+ f'<h2>{_html.escape(rec["name"])} '
91
+ f'<span class="badge {s_cls}">{s_txt}</span> '
92
+ f'<span class="dur">{rec["duration"]:.2f}s</span></h2>\n'
93
+ f'<pre>{_render_output_pre(rec["output"])}</pre>'
94
+ )
95
+
96
+ content = (
97
+ '<!DOCTYPE html>\n<html lang="en">\n<head>\n'
98
+ '<meta charset="utf-8">\n'
99
+ f'<title>{_html.escape(test_name)}</title>\n'
100
+ '<style>\n'
101
+ '*{box-sizing:border-box;margin:0;padding:0}\n'
102
+ 'body{font-family:"Consolas","Courier New",monospace;background:#1e1e1e;'
103
+ 'color:#d4d4d4;padding:24px;font-size:13px;line-height:1.5}\n'
104
+ 'h1{color:#9cdcfe;font-size:18px;margin-bottom:6px}\n'
105
+ 'h2{color:#c586c0;font-size:14px;margin:20px 0 6px}\n'
106
+ '.meta{color:#808080;margin-bottom:24px;font-size:12px}\n'
107
+ '.dur{color:#808080;font-weight:400;font-size:12px}\n'
108
+ '.badge{font-weight:700;padding:2px 8px;border-radius:3px}\n'
109
+ '.pass{color:#4ec9b0}.fail{color:#f44747}\n'
110
+ 'pre{background:#252526;border-left:3px solid #555;padding:12px;'
111
+ 'overflow-x:auto;white-space:pre-wrap;word-break:break-all}\n'
112
+ '.cmd-trace{color:#dcdcaa}\n'
113
+ '</style>\n</head>\n<body>\n'
114
+ f'<h1>{_html.escape(test_name)}</h1>\n'
115
+ '<div class="meta">'
116
+ f'Status: <span class="badge {status_cls}">{status_text}</span>'
117
+ f'&nbsp;&nbsp;Duration: {duration:.2f}s'
118
+ '</div>\n'
119
+ + "\n".join(sections) +
120
+ '\n</body>\n</html>'
121
+ )
122
+ path = os.path.join(test_dir, "report.html")
123
+ with open(path, "w") as f:
124
+ f.write(content)
125
+ return path
126
+
127
+
128
+ def generate_robot_file(test_name, steps, resc_host_path, job_index, test_index, snapshot=""):
129
+ """Generate a .robot suite: one keyword per step, one test case that calls them in order.
130
+
131
+ One test case means Renode's per-test lifecycle fires exactly once — no inter-step teardowns.
132
+ run_tests.py injects Suite Setup Setup (since we define none), which establishes the
133
+ Remote library connection. The test case boots the emulation, then calls each keyword.
134
+ """
135
+ test_dir = os.path.join(ARTIFACTS_DIR, "tests")
136
+ os.makedirs(test_dir, exist_ok=True)
137
+
138
+ keywords = []
139
+ for step_idx, step in enumerate(steps):
140
+ kw_name = step.get("name") or f"Step {step_idx + 1}"
141
+ body = [f" {l.strip()}" for l in (step.get("script") or "").splitlines() if l.strip()]
142
+ if not body:
143
+ body = [" No Operation"]
144
+ keywords.append(kw_name + "\n" + "\n".join(body))
145
+
146
+ if snapshot:
147
+ boot = [f" Execute Command Load @{to_container_path(snapshot)}",
148
+ " Execute Command start"]
149
+ else:
150
+ boot = [f" Execute Command include @{to_container_path(resc_host_path)}"]
151
+
152
+ kw_calls = [f" {step.get('name') or f'Step {i + 1}'}" for i, step in enumerate(steps)]
153
+ test_case = test_name + "\n" + "\n".join(boot + kw_calls)
154
+
155
+ content = (
156
+ "*** Keywords ***\n"
157
+ + "\n\n".join(keywords) + "\n\n"
158
+ + "*** Test Cases ***\n"
159
+ + test_case + "\n"
160
+ )
161
+
162
+ path = os.path.join(test_dir, f"job_{job_index}_test_{test_index}.robot")
163
+ with open(path, "w") as f:
164
+ f.write(content)
165
+ return path
166
+
167
+
168
+ # ── test runners ──────────────────────────────────────────────────────────────
169
+
170
+ def _run_robot_test(test, steps, ctx, test_index):
171
+ """Run a robot test. Returns a single StepResult for the whole test."""
172
+ test_name = test.get("name", f"test_{test_index}")
173
+ effective_snapshot = ctx.snapshot or test.get("snapshot", "")
174
+ if effective_snapshot and not os.path.isfile(effective_snapshot):
175
+ raise FileNotFoundError(f"Snapshot not found: {effective_snapshot}")
176
+
177
+ extra_args = _parse_extra_args(test) + ctx.renode_test_args
178
+ if ctx.live:
179
+ extra_args += ["--verbose"]
180
+ interactive = test.get("interactive", False)
181
+
182
+ print(f"\n[TEST] {test_name}")
183
+ if effective_snapshot:
184
+ print(f" [SNAP] {effective_snapshot}")
185
+ for step in steps:
186
+ print(f" {step.get('name', '?')}")
187
+
188
+ test_dir = _test_results_dir(ctx.results_dir, test_index, test_name)
189
+
190
+ if len(steps) == 1 and steps[0].get("file"):
191
+ if effective_snapshot:
192
+ print(" [WARN] snapshot ignored for file-based robot tests.")
193
+ robot_file = steps[0]["file"]
194
+ else:
195
+ if any(s.get("file") for s in steps):
196
+ raise ValueError(f"Test '{test_name}': mixing file: and script: steps is not supported.")
197
+ robot_file = generate_robot_file(
198
+ test_name, steps, ctx.resc_host_path, ctx.job_index, test_index,
199
+ snapshot=effective_snapshot,
200
+ )
201
+
202
+ t0 = time.monotonic()
203
+ passed = run_renode_test(robot_file, ctx.image, test_dir,
204
+ resc_host_path=ctx.resc_host_path,
205
+ extra_args=extra_args, interactive=interactive,
206
+ runtime=ctx.runtime)
207
+ duration = time.monotonic() - t0
208
+ # Register renode-test's own per-test reports so the manifest can deep-link them.
209
+ # 'report_html' is the same key bash uses, so a dashboard finds it uniformly.
210
+ artifacts = {}
211
+ for label, fname in (("report_html", "report.html"),
212
+ ("log_html", "log.html"),
213
+ ("robot_xml", "robot_output.xml")):
214
+ fpath = os.path.join(test_dir, fname)
215
+ if os.path.exists(fpath):
216
+ artifacts[label] = fpath
217
+ return [StepResult(
218
+ name=test_name, type="robot", passed=passed, duration=duration,
219
+ artifacts=artifacts,
220
+ )]
221
+
222
+
223
+ def _run_shell_test(test, steps, ctx, test_index):
224
+ """Run all steps of a shell test, each in a one-off container of the resolved image
225
+ (or on the host under --no-docker). One StepResult per test, one report.html —
226
+ mirrors the robot layout (a multi-step test is a single row named after the test).
227
+ """
228
+ test_name = test.get("name", f"test_{test_index}")
229
+ image = resolve_shell_image(test, ctx.image, label=test_name)
230
+ test_dir = _test_results_dir(ctx.results_dir, test_index, test_name)
231
+ where = "host" if _core.NO_DOCKER else f"image: {image}"
232
+ print(f"\n[TEST] {test_name} (shell, {where})")
233
+
234
+ step_records = []
235
+ all_commands = []
236
+ combined_stdout = []
237
+ total_duration = 0.0
238
+ all_passed = True
239
+
240
+ for step_idx, step in enumerate(steps):
241
+ step_name = step.get("name", f"step_{step_idx}")
242
+ script = step.get("script", "")
243
+ print(f" {step_name}")
244
+
245
+ t0 = time.monotonic()
246
+ stdout, passed = run_in_container(script, image, ctx.live)
247
+ duration = time.monotonic() - t0
248
+
249
+ total_duration += duration
250
+ all_passed = all_passed and passed
251
+ all_commands.extend(l for l in script.splitlines() if l.strip())
252
+ combined_stdout.append(f"# {step_name}\n{stdout}".rstrip())
253
+ step_records.append({"name": step_name, "passed": passed,
254
+ "duration": duration, "output": stdout})
255
+
256
+ html_path = _write_shell_html(test_dir, test_name, step_records,
257
+ all_passed, total_duration)
258
+ return [StepResult(
259
+ name=test_name, type="shell", passed=all_passed, duration=total_duration,
260
+ commands=all_commands, stdout="\n\n".join(combined_stdout), stderr="",
261
+ artifacts={"report_html": html_path},
262
+ )]
263
+
264
+
265
+ # ── registry entries for built-in types ──────────────────────────────────────
266
+
267
+ @TEST_RUNNERS.register("robot")
268
+ def robot_runner(test, steps, ctx, index):
269
+ return _run_robot_test(test, steps, ctx, index)
270
+
271
+
272
+ @TEST_RUNNERS.register("shell")
273
+ def shell_runner(test, steps, ctx, index):
274
+ return _run_shell_test(test, steps, ctx, index)
275
+
276
+
277
+ # Test types renamed in this schema version → current name. Old configs still run;
278
+ # the validator emits a matching migration warning.
279
+ _RENAMED_TEST_TYPES = {"bash": "shell", "docker": "shell"}
280
+
281
+
282
+ # ── pre-sim build steps ───────────────────────────────────────────────────────
283
+
284
+ def run_build_steps(blocks, default_image, results_dir, live=False):
285
+ """Run the pre-sim `build:` steps (compile firmware, generate/fetch files, ...).
286
+
287
+ Each block is a shell step run in a container of its image (or on the host under
288
+ --no-docker), with the project bind-mounted — so anything it writes into the project
289
+ is there for the sim and tests. Returns one StepResult per block (type "build") so
290
+ builds are reported like tests; stops at the first failing block, because the
291
+ simulation depends on these outputs (the caller skips the sim when one fails).
292
+ """
293
+ if not blocks:
294
+ return []
295
+ print("\n[BUILD] preparing inputs ...")
296
+ results = []
297
+ for b_idx, block in enumerate(blocks):
298
+ block = block or {}
299
+ name = block.get("name", f"build_{b_idx}")
300
+ image = resolve_shell_image(block, default_image, label=name)
301
+ where = "host" if _core.NO_DOCKER else f"image: {image}"
302
+ print(f" {name} ({where})")
303
+
304
+ step_records = []
305
+ all_commands = []
306
+ combined_stdout = []
307
+ total_duration = 0.0
308
+ all_passed = True
309
+
310
+ for step_idx, step in enumerate(_normalize_steps(block)):
311
+ script = step.get("script", "")
312
+ if not script.strip():
313
+ continue
314
+ step_name = step.get("name", f"step_{step_idx}")
315
+ t0 = time.monotonic()
316
+ stdout, passed = run_in_container(script, image, live)
317
+ duration = time.monotonic() - t0
318
+ total_duration += duration
319
+ all_passed = all_passed and passed
320
+ all_commands.extend(l for l in script.splitlines() if l.strip())
321
+ combined_stdout.append(f"# {step_name}\n{stdout}".rstrip())
322
+ step_records.append({"name": step_name, "passed": passed,
323
+ "duration": duration, "output": stdout})
324
+
325
+ slug = re.sub(r"[^\w-]", "_", name)[:40]
326
+ build_dir = os.path.join(results_dir, f"build_{b_idx}_{slug}")
327
+ html_path = _write_shell_html(build_dir, name, step_records,
328
+ all_passed, total_duration)
329
+ results.append(StepResult(
330
+ name=name, type="build", passed=all_passed, duration=total_duration,
331
+ commands=all_commands, stdout="\n\n".join(combined_stdout), stderr="",
332
+ artifacts={"report_html": html_path}))
333
+ if not all_passed:
334
+ print(f"[BUILD] '{name}' failed — aborting build.")
335
+ break
336
+ return results
337
+
338
+
339
+ # ── driver ────────────────────────────────────────────────────────────────────
340
+
341
+ def _needs_sim(test):
342
+ """Whether a test requires the Renode simulation (the generated resc). Robot tests
343
+ always do; others honour `requires_sim` (default True)."""
344
+ rtype = _RENAMED_TEST_TYPES.get(test.get("type", "robot"), test.get("type", "robot"))
345
+ if rtype == "robot":
346
+ return True
347
+ return bool(test.get("requires_sim", True))
348
+
349
+
350
+ def run_tests(tests, ctx, fail_fast=False):
351
+ """Execute tests in order, returning their StepResults (the caller emits the reports,
352
+ combined with the build steps'). Sim-backed tests get their own Renode session; a test
353
+ that needs the sim but has no generated resc (no machines) fails with a clear note.
354
+ With fail_fast, stop after the first failing test."""
355
+ results = []
356
+ for i, test in enumerate(tests):
357
+ rtype = test.get("type", "robot")
358
+ if rtype in _RENAMED_TEST_TYPES:
359
+ new = _RENAMED_TEST_TYPES[rtype]
360
+ print(f"[WARN] test type '{rtype}' was renamed to '{new}' — update 'type:'.")
361
+ rtype = new
362
+ test = {**test, "type": new}
363
+
364
+ if _needs_sim(test) and not ctx.resc_host_path:
365
+ name = test.get("name", f"test_{i}")
366
+ print(f"\n[TEST] {name}\n [SKIP] needs a simulation but no machine is "
367
+ "defined (set 'requires_sim: false' for a sim-independent test).")
368
+ results.append(StepResult(
369
+ name=name, type=rtype, passed=False, duration=0.0,
370
+ stderr="requires a simulation but no machine/resc is available"))
371
+ else:
372
+ runner = TEST_RUNNERS.get(rtype)
373
+ results.extend(runner(test, _normalize_steps(test), ctx, i))
374
+
375
+ if fail_fast and not results[-1].passed:
376
+ remaining = len(tests) - (i + 1)
377
+ if remaining:
378
+ print(f"\n[FAIL-FAST] Stopping after a failing test ({remaining} skipped).")
379
+ break
380
+ return results
hector/scaffold.py ADDED
@@ -0,0 +1,75 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """Project scaffolding: `hector init`."""
3
+
4
+ import os
5
+ import sys
6
+
7
+ _TEMPLATE = """\
8
+ version: "0.1"
9
+ renode_version: "{renode_version}"
10
+
11
+ # arguments: scalar defaults, each overridable by an env var of the same name.
12
+ arguments:
13
+ BIN: firmware.elf
14
+
15
+ machines:
16
+ {board}:
17
+ platform:
18
+ - platforms/boards/{board}.repl
19
+ firmware: ${{BIN}}
20
+
21
+ # Uncomment to capture UART output to a file:
22
+ # mappings: |
23
+ # {board}.usart2 -> file:results/uart.log
24
+
25
+ # Uncomment to add tests (run with: hector test):
26
+ # tests:
27
+ # - name: Firmware boots
28
+ # type: robot
29
+ # script: |
30
+ # Create Terminal Tester sysbus.usart2
31
+ # Wait For Line On Uart Ready timeout=30
32
+ """
33
+
34
+ _PLATFORMS_NOTE = """\
35
+ [INIT] Next steps:
36
+ 1. Add your .repl platform file to platforms/boards/{board}.repl
37
+ 2. Set BIN to your firmware path, or override at runtime: BIN=path/to/fw.elf hector run
38
+ 3. Run the simulation: hector run
39
+ 4. Run tests: hector test
40
+ 5. Debug a machine: hector run --debug {board}:3333
41
+ """
42
+
43
+
44
+ def scaffold_init():
45
+ """Interactively create a starter .hector.yaml config in the current directory."""
46
+ target = ".hector.yaml"
47
+ if os.path.exists(target):
48
+ print(f"[INIT] {target} already exists — not overwriting.")
49
+ print(f"[INIT] Delete or rename it, then run 'hector init' again.")
50
+ sys.exit(1)
51
+
52
+ print("Hector project initialisation")
53
+ print(" Press Enter to accept the default shown in [brackets].\n")
54
+
55
+ board = _prompt(" Board / machine name", "stm32f4_discovery")
56
+ renode_version = _prompt(" Renode version ", "1.16.1")
57
+
58
+ content = _TEMPLATE.format(board=board, renode_version=renode_version)
59
+ with open(target, "w") as fh:
60
+ fh.write(content)
61
+
62
+ os.makedirs(os.path.join("platforms", "boards"), exist_ok=True)
63
+ os.makedirs("results", exist_ok=True)
64
+
65
+ print(f"\n[INIT] Created {target}")
66
+ print(_PLATFORMS_NOTE.format(board=board))
67
+
68
+
69
+ def _prompt(label: str, default: str) -> str:
70
+ try:
71
+ value = input(f"{label} [{default}]: ").strip()
72
+ except (EOFError, KeyboardInterrupt):
73
+ print()
74
+ sys.exit(0)
75
+ return value or default