maestro-framework 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.
- maestro_core/__init__.py +43 -0
- maestro_core/cli.py +180 -0
- maestro_core/config/__init__.py +32 -0
- maestro_core/config/devices.py +152 -0
- maestro_core/config/loader.py +111 -0
- maestro_core/config/models.py +86 -0
- maestro_core/config/settings.py +60 -0
- maestro_core/detect/__init__.py +31 -0
- maestro_core/detect/adb_detector.py +67 -0
- maestro_core/detect/base.py +57 -0
- maestro_core/detect/camera_detector.py +72 -0
- maestro_core/detect/registry.py +64 -0
- maestro_core/detect/serial_detector.py +42 -0
- maestro_core/detect/ssh_detector.py +83 -0
- maestro_core/drivers/__init__.py +59 -0
- maestro_core/drivers/_cli.py +61 -0
- maestro_core/drivers/adb_turboadb.py +138 -0
- maestro_core/drivers/base.py +333 -0
- maestro_core/drivers/dlt.py +116 -0
- maestro_core/drivers/fake.py +228 -0
- maestro_core/drivers/power.py +92 -0
- maestro_core/drivers/registry.py +69 -0
- maestro_core/drivers/ssh_turbossh.py +113 -0
- maestro_core/drivers/webcam.py +96 -0
- maestro_core/endurance/__init__.py +20 -0
- maestro_core/endurance/health.py +72 -0
- maestro_core/endurance/loop.py +97 -0
- maestro_core/endurance/rollup.py +65 -0
- maestro_core/endurance/stop.py +97 -0
- maestro_core/engine/__init__.py +30 -0
- maestro_core/engine/_pytest_scenario.py +29 -0
- maestro_core/engine/expect.py +53 -0
- maestro_core/engine/plugin.py +112 -0
- maestro_core/engine/runner.py +435 -0
- maestro_core/errors.py +63 -0
- maestro_core/plugins.py +88 -0
- maestro_core/proc.py +154 -0
- maestro_core/py.typed +0 -0
- maestro_core/reporting/__init__.py +45 -0
- maestro_core/reporting/junit.py +108 -0
- maestro_core/reporting/records.py +146 -0
- maestro_core/reporting/reporter.py +86 -0
- maestro_core/reporting/sink.py +124 -0
- maestro_core/scenario/__init__.py +84 -0
- maestro_core/scenario/graph.py +318 -0
- maestro_core/scenario/io.py +102 -0
- maestro_core/scenario/models.py +276 -0
- maestro_core/scenario/schema.py +41 -0
- maestro_core/scenario/validation.py +247 -0
- maestro_core/steps/__init__.py +55 -0
- maestro_core/steps/builtins.py +270 -0
- maestro_core/steps/context.py +125 -0
- maestro_core/steps/discovery.py +90 -0
- maestro_core/steps/registry.py +245 -0
- maestro_core/steps/script.py +128 -0
- maestro_core/timeutil.py +87 -0
- maestro_framework-0.1.0.dist-info/METADATA +99 -0
- maestro_framework-0.1.0.dist-info/RECORD +97 -0
- maestro_framework-0.1.0.dist-info/WHEEL +4 -0
- maestro_framework-0.1.0.dist-info/entry_points.txt +28 -0
- maestro_server/__init__.py +16 -0
- maestro_server/api/__init__.py +19 -0
- maestro_server/api/benches.py +88 -0
- maestro_server/api/crud_router.py +179 -0
- maestro_server/api/deps.py +42 -0
- maestro_server/api/misc.py +132 -0
- maestro_server/api/resources.py +331 -0
- maestro_server/api/runs.py +252 -0
- maestro_server/auth.py +61 -0
- maestro_server/cli.py +46 -0
- maestro_server/db/__init__.py +48 -0
- maestro_server/db/base.py +53 -0
- maestro_server/db/models.py +249 -0
- maestro_server/db/session.py +52 -0
- maestro_server/integrations/__init__.py +14 -0
- maestro_server/integrations/base.py +38 -0
- maestro_server/integrations/dispatch.py +43 -0
- maestro_server/integrations/email.py +78 -0
- maestro_server/integrations/jira_xray.py +57 -0
- maestro_server/main.py +124 -0
- maestro_server/scheduler.py +183 -0
- maestro_server/schemas.py +520 -0
- maestro_server/services/__init__.py +14 -0
- maestro_server/services/bench.py +40 -0
- maestro_server/services/crud.py +125 -0
- maestro_server/services/detection.py +102 -0
- maestro_server/services/run_trigger.py +137 -0
- maestro_server/services/runs.py +300 -0
- maestro_server/settings.py +75 -0
- maestro_server/web/assets/index-BQIDZnuk.js +275 -0
- maestro_server/web/assets/index-Cee9fdIV.css +1 -0
- maestro_server/web/favicon.svg +15 -0
- maestro_server/web/index.html +15 -0
- maestro_tools/__init__.py +17 -0
- maestro_tools/cli.py +82 -0
- maestro_tools/generator.py +34 -0
- maestro_tools/invoke.py +110 -0
maestro_core/__init__.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""MAESTRO core — the headless heart of the platform.
|
|
2
|
+
|
|
3
|
+
MAESTRO (Modular Automotive Embedded System Test, Reporting & Orchestration) is an
|
|
4
|
+
offline-capable test-automation platform for automotive Hardware-in-the-Loop
|
|
5
|
+
(HIL) and embedded device testing.
|
|
6
|
+
|
|
7
|
+
``maestro_core`` contains everything needed to execute a test *scenario* with no
|
|
8
|
+
server and no UI:
|
|
9
|
+
|
|
10
|
+
* :mod:`maestro_core.scenario` — the versioned JSON scenario model (the single
|
|
11
|
+
source of truth authored on the canvas) plus validation and JSON-Schema export.
|
|
12
|
+
* :mod:`maestro_core.steps` — the step registry, the per-step execution context,
|
|
13
|
+
the built-in step library, and the generic script-wrapping adapter.
|
|
14
|
+
* :mod:`maestro_core.drivers` — thin adapters that shell out to the user's own
|
|
15
|
+
device tools (turboadb, turbossh, power scripts, DLT, webcam), plus fully
|
|
16
|
+
functional fake drivers for hardware-free testing.
|
|
17
|
+
* :mod:`maestro_core.detect` — device detectors (serial, adb, camera, ssh).
|
|
18
|
+
* :mod:`maestro_core.engine` — the pytest plugin and small generic runner that
|
|
19
|
+
turn a scenario into executed, reported tests.
|
|
20
|
+
* :mod:`maestro_core.endurance` — the long-running cycle loop and stop conditions.
|
|
21
|
+
* :mod:`maestro_core.reporting` — the attachment pipeline and Allure/JUnit hooks.
|
|
22
|
+
* :mod:`maestro_core.config` — layered TOML configuration loading.
|
|
23
|
+
|
|
24
|
+
Author:
|
|
25
|
+
Naveen Daniel Kennedy <nvnkennedy@gmail.com>
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
#: Semantic version of the MAESTRO core package (single source of truth; the
|
|
31
|
+
#: build backend reads this string to stamp the wheel).
|
|
32
|
+
__version__ = "0.1.0"
|
|
33
|
+
|
|
34
|
+
#: Human-readable product name, backronym, and tagline used across the UI, the
|
|
35
|
+
#: About page, and the docs. MAESTRO conducts the test bench the way a maestro
|
|
36
|
+
#: leads an orchestra — keeping devices, scenarios, and endurance cycles in time —
|
|
37
|
+
#: which is fitting for automotive infotainment, where the head unit conducts the
|
|
38
|
+
#: vehicle's audio, video, and connectivity.
|
|
39
|
+
PRODUCT_NAME = "MAESTRO"
|
|
40
|
+
PRODUCT_BACKRONYM = "Modular Automotive Embedded System Test, Reporting & Orchestration"
|
|
41
|
+
PRODUCT_TAGLINE = "Automotive embedded test orchestration"
|
|
42
|
+
|
|
43
|
+
__all__ = ["PRODUCT_BACKRONYM", "PRODUCT_NAME", "PRODUCT_TAGLINE", "__version__"]
|
maestro_core/cli.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Headless command-line interface for ``maestro-core``.
|
|
2
|
+
|
|
3
|
+
Run, validate, and inspect scenarios with no server and no UI:
|
|
4
|
+
|
|
5
|
+
* ``maestro-core run <scenario.json> --bench <bench.toml>`` — execute a scenario.
|
|
6
|
+
* ``maestro-core validate <scenario.json>`` — report validation issues.
|
|
7
|
+
* ``maestro-core schema [--out file]`` — export the scenario JSON-Schema.
|
|
8
|
+
* ``maestro-core steps`` — list every registered step.
|
|
9
|
+
* ``maestro-core detect [--host H]`` — run device detectors and print proposals.
|
|
10
|
+
|
|
11
|
+
Author:
|
|
12
|
+
Naveen Daniel Kennedy <nvnkennedy@gmail.com>
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import json
|
|
19
|
+
import sys
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from maestro_core import PRODUCT_BACKRONYM, PRODUCT_NAME, __version__
|
|
24
|
+
from maestro_core.config import build_devices, load_bench_toml
|
|
25
|
+
from maestro_core.detect import run_detection
|
|
26
|
+
from maestro_core.engine import RunOptions, run_scenario
|
|
27
|
+
from maestro_core.engine.plugin import ScenarioCase, run_via_pytest
|
|
28
|
+
from maestro_core.reporting import write_junit, write_run_record
|
|
29
|
+
from maestro_core.scenario import (
|
|
30
|
+
load_scenario_file,
|
|
31
|
+
parse_scenario,
|
|
32
|
+
scenario_json_schema,
|
|
33
|
+
validate_scenario,
|
|
34
|
+
)
|
|
35
|
+
from maestro_core.steps import all_steps, known_step_names, load_all_steps
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
39
|
+
"""Construct the argument parser for the CLI."""
|
|
40
|
+
parser = argparse.ArgumentParser(
|
|
41
|
+
prog="maestro-core", description=f"{PRODUCT_NAME} — {PRODUCT_BACKRONYM}"
|
|
42
|
+
)
|
|
43
|
+
parser.add_argument("-V", "--version", action="version", version=f"maestro-core {__version__}")
|
|
44
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
45
|
+
|
|
46
|
+
run = sub.add_parser("run", help="run a scenario on a bench")
|
|
47
|
+
run.add_argument("scenario", help="path to the scenario JSON")
|
|
48
|
+
run.add_argument("--bench", required=True, help="path to the bench TOML")
|
|
49
|
+
run.add_argument("--out", default=None, help="output directory for this run")
|
|
50
|
+
run.add_argument("--mode", choices=["serial", "parallel", "step"], default="serial")
|
|
51
|
+
run.add_argument("--user-steps", default=None, help="directory of drop-in step scripts")
|
|
52
|
+
run.add_argument(
|
|
53
|
+
"--pytest", action="store_true", help="execute through pytest (writes an Allure report)"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
validate = sub.add_parser("validate", help="validate a scenario")
|
|
57
|
+
validate.add_argument("scenario", help="path to the scenario JSON")
|
|
58
|
+
|
|
59
|
+
schema = sub.add_parser("schema", help="export the scenario JSON-Schema")
|
|
60
|
+
schema.add_argument("--out", default=None, help="write the schema here instead of stdout")
|
|
61
|
+
|
|
62
|
+
sub.add_parser("steps", help="list registered steps")
|
|
63
|
+
|
|
64
|
+
detect = sub.add_parser("detect", help="run device detectors")
|
|
65
|
+
detect.add_argument("--host", default=None, help="ssh host to probe")
|
|
66
|
+
detect.add_argument("--port", type=int, default=22, help="ssh port to probe")
|
|
67
|
+
return parser
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _cmd_run(args: argparse.Namespace) -> int:
|
|
71
|
+
"""Execute the ``run`` subcommand."""
|
|
72
|
+
load_all_steps(args.user_steps)
|
|
73
|
+
scenario = load_scenario_file(args.scenario, validate=True, known_steps=known_step_names())
|
|
74
|
+
bench = load_bench_toml(args.bench)
|
|
75
|
+
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
76
|
+
out = (
|
|
77
|
+
Path(args.out)
|
|
78
|
+
if args.out
|
|
79
|
+
else Path(".maestro") / "runs" / f"{scenario.metadata.id}-{stamp}"
|
|
80
|
+
)
|
|
81
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
82
|
+
|
|
83
|
+
devices = build_devices(bench, output_dir=out / "artifacts")
|
|
84
|
+
options = RunOptions(bench=bench.name, mode=args.mode, user_steps_dir=args.user_steps)
|
|
85
|
+
|
|
86
|
+
if args.pytest:
|
|
87
|
+
case = ScenarioCase(
|
|
88
|
+
id=f"{scenario.metadata.id}[{bench.name}]",
|
|
89
|
+
scenario=scenario,
|
|
90
|
+
devices=devices,
|
|
91
|
+
run_dir=out,
|
|
92
|
+
options=options,
|
|
93
|
+
)
|
|
94
|
+
results = run_via_pytest([case], out)
|
|
95
|
+
run = results.get(case.id)
|
|
96
|
+
if run is None:
|
|
97
|
+
print("error: the scenario did not run under pytest", file=sys.stderr)
|
|
98
|
+
return 2
|
|
99
|
+
else:
|
|
100
|
+
run = run_scenario(scenario, devices, out, options=options)
|
|
101
|
+
|
|
102
|
+
write_run_record(run, out / "run.json")
|
|
103
|
+
write_junit(run, out / "junit.xml")
|
|
104
|
+
|
|
105
|
+
print(f"{run.scenario_id} on {run.bench}: {run.status.upper()}")
|
|
106
|
+
print(f" steps: {run.summary.get('passed', 0)} passed, {run.summary.get('failed', 0)} failed")
|
|
107
|
+
if run.is_endurance:
|
|
108
|
+
for group_id, rollup in run.summary.get("endurance", {}).items():
|
|
109
|
+
print(
|
|
110
|
+
f" endurance[{group_id}]: {rollup['passed']}/{rollup['completed_cycles']} cycles "
|
|
111
|
+
f"passed, first failure: {rollup['first_failure_cycle']}"
|
|
112
|
+
)
|
|
113
|
+
print(f" output: {out}")
|
|
114
|
+
return 0 if run.status == "passed" else 1
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _cmd_validate(args: argparse.Namespace) -> int:
|
|
118
|
+
"""Execute the ``validate`` subcommand."""
|
|
119
|
+
load_all_steps(None)
|
|
120
|
+
scenario = parse_scenario(Path(args.scenario).read_text(encoding="utf-8"))
|
|
121
|
+
issues = validate_scenario(scenario, known_steps=known_step_names())
|
|
122
|
+
if not issues:
|
|
123
|
+
print("valid: no issues found")
|
|
124
|
+
return 0
|
|
125
|
+
for issue in issues:
|
|
126
|
+
where = f" @{issue.location}" if issue.location else ""
|
|
127
|
+
print(f"[{issue.severity}] {issue.code}{where}: {issue.message}")
|
|
128
|
+
return 0 if all(i.severity != "error" for i in issues) else 1
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _cmd_schema(args: argparse.Namespace) -> int:
|
|
132
|
+
"""Execute the ``schema`` subcommand."""
|
|
133
|
+
text = json.dumps(scenario_json_schema(), indent=2)
|
|
134
|
+
if args.out:
|
|
135
|
+
Path(args.out).write_text(text + "\n", encoding="utf-8")
|
|
136
|
+
print(f"wrote schema to {args.out}")
|
|
137
|
+
else:
|
|
138
|
+
print(text)
|
|
139
|
+
return 0
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _cmd_steps(_: argparse.Namespace) -> int:
|
|
143
|
+
"""Execute the ``steps`` subcommand."""
|
|
144
|
+
load_all_steps(None)
|
|
145
|
+
for name, spec in sorted(all_steps().items()):
|
|
146
|
+
params = ", ".join(f"{p.name}:{p.kind}{'' if p.required else '?'}" for p in spec.params)
|
|
147
|
+
suffix = " [free-form]" if spec.freeform else ""
|
|
148
|
+
print(f"{name} ({spec.category}){suffix}")
|
|
149
|
+
if params:
|
|
150
|
+
print(f" {params}")
|
|
151
|
+
return 0
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _cmd_detect(args: argparse.Namespace) -> int:
|
|
155
|
+
"""Execute the ``detect`` subcommand."""
|
|
156
|
+
proposals, errors = run_detection(host=args.host, port=args.port)
|
|
157
|
+
output = {
|
|
158
|
+
"proposals": {name: [p.model_dump() for p in items] for name, items in proposals.items()},
|
|
159
|
+
"errors": errors,
|
|
160
|
+
}
|
|
161
|
+
print(json.dumps(output, indent=2))
|
|
162
|
+
return 0
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def main(argv: list[str] | None = None) -> int:
|
|
166
|
+
"""CLI entry point. Returns a process exit code."""
|
|
167
|
+
parser = _build_parser()
|
|
168
|
+
args = parser.parse_args(argv)
|
|
169
|
+
handlers = {
|
|
170
|
+
"run": _cmd_run,
|
|
171
|
+
"validate": _cmd_validate,
|
|
172
|
+
"schema": _cmd_schema,
|
|
173
|
+
"steps": _cmd_steps,
|
|
174
|
+
"detect": _cmd_detect,
|
|
175
|
+
}
|
|
176
|
+
return handlers[args.command](args)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
if __name__ == "__main__":
|
|
180
|
+
sys.exit(main())
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""MAESTRO configuration — typed settings, layered TOML, and the bench bridge.
|
|
2
|
+
|
|
3
|
+
Author:
|
|
4
|
+
Naveen Daniel Kennedy <nvnkennedy@gmail.com>
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from maestro_core.config.devices import build_devices, driver_name_for
|
|
10
|
+
from maestro_core.config.loader import (
|
|
11
|
+
deep_merge,
|
|
12
|
+
load_bench_toml,
|
|
13
|
+
load_layered,
|
|
14
|
+
load_toml,
|
|
15
|
+
write_bench_toml,
|
|
16
|
+
)
|
|
17
|
+
from maestro_core.config.models import BenchConfig, DeviceConfig
|
|
18
|
+
from maestro_core.config.settings import MaestroSettings, get_settings
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"BenchConfig",
|
|
22
|
+
"DeviceConfig",
|
|
23
|
+
"MaestroSettings",
|
|
24
|
+
"build_devices",
|
|
25
|
+
"deep_merge",
|
|
26
|
+
"driver_name_for",
|
|
27
|
+
"get_settings",
|
|
28
|
+
"load_bench_toml",
|
|
29
|
+
"load_layered",
|
|
30
|
+
"load_toml",
|
|
31
|
+
"write_bench_toml",
|
|
32
|
+
]
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Build a live :class:`~maestro_core.drivers.base.Devices` bundle from a bench.
|
|
2
|
+
|
|
3
|
+
This is the bridge between configuration (data) and drivers (behaviour): it picks
|
|
4
|
+
the right driver for each device, maps the device's endpoint fields onto that
|
|
5
|
+
driver's constructor arguments, instantiates it, and files it under the right
|
|
6
|
+
capability (power / camera / log) or device role.
|
|
7
|
+
|
|
8
|
+
Author:
|
|
9
|
+
Naveen Daniel Kennedy <nvnkennedy@gmail.com>
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from maestro_core.config.models import BenchConfig, DeviceConfig
|
|
19
|
+
from maestro_core.drivers.base import (
|
|
20
|
+
CameraController,
|
|
21
|
+
DeviceController,
|
|
22
|
+
Devices,
|
|
23
|
+
LogController,
|
|
24
|
+
PowerController,
|
|
25
|
+
)
|
|
26
|
+
from maestro_core.drivers.registry import create_driver
|
|
27
|
+
from maestro_core.errors import ConfigError
|
|
28
|
+
|
|
29
|
+
#: Maps a device ``type`` to the driver that handles it.
|
|
30
|
+
_DRIVER_BY_TYPE = {
|
|
31
|
+
"android": "turboadb",
|
|
32
|
+
"adb": "turboadb",
|
|
33
|
+
"qnx": "turbossh",
|
|
34
|
+
"linux": "turbossh",
|
|
35
|
+
"ssh": "turbossh",
|
|
36
|
+
"camera": "webcam",
|
|
37
|
+
"power": "power",
|
|
38
|
+
"dlt": "dlt",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def driver_name_for(device: DeviceConfig) -> str:
|
|
43
|
+
"""Return the driver name for a device (explicit ``driver``, else by ``type``).
|
|
44
|
+
|
|
45
|
+
A ``type`` that is not a known category is assumed to *be* a driver name (so
|
|
46
|
+
``type = "fake_power"`` works directly).
|
|
47
|
+
"""
|
|
48
|
+
if device.driver:
|
|
49
|
+
return device.driver
|
|
50
|
+
return _DRIVER_BY_TYPE.get(device.type.lower(), device.type)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _driver_kwargs(device: DeviceConfig, driver_name: str, output_dir: Path) -> dict[str, Any]:
|
|
54
|
+
"""Map a device's fields onto the named driver's constructor arguments."""
|
|
55
|
+
if driver_name == "turboadb":
|
|
56
|
+
kwargs: dict[str, Any] = {
|
|
57
|
+
"serial": device.adb_serial,
|
|
58
|
+
"adb_path": device.adb_path,
|
|
59
|
+
"adb_host": device.adb_host,
|
|
60
|
+
"adb_port": device.adb_port,
|
|
61
|
+
"timeout": device.timeout,
|
|
62
|
+
}
|
|
63
|
+
elif driver_name == "turbossh":
|
|
64
|
+
kwargs = {
|
|
65
|
+
"host": device.ssh_host,
|
|
66
|
+
"user": device.ssh_user,
|
|
67
|
+
"port": device.ssh_port or 22,
|
|
68
|
+
"key": device.ssh_key,
|
|
69
|
+
"use_stored": device.ssh_use_stored,
|
|
70
|
+
"service": device.ssh_service,
|
|
71
|
+
"domain": device.ssh_domain,
|
|
72
|
+
"timeout": device.timeout,
|
|
73
|
+
}
|
|
74
|
+
elif driver_name in ("webcam", "fake_camera"):
|
|
75
|
+
camera_index = device.camera_index if device.camera_index is not None else device.index
|
|
76
|
+
kwargs = {
|
|
77
|
+
"index": camera_index if camera_index is not None else 0,
|
|
78
|
+
"name": device.camera_name or device.name,
|
|
79
|
+
"output_dir": str(output_dir),
|
|
80
|
+
}
|
|
81
|
+
if driver_name == "fake_camera":
|
|
82
|
+
kwargs = {"output_dir": str(output_dir)}
|
|
83
|
+
elif driver_name == "power":
|
|
84
|
+
kwargs = {
|
|
85
|
+
"script": device.script,
|
|
86
|
+
"com_port": device.com_port,
|
|
87
|
+
"channel": device.power_channel,
|
|
88
|
+
"python": device.python,
|
|
89
|
+
"timeout": device.timeout,
|
|
90
|
+
}
|
|
91
|
+
elif driver_name in ("dlt",):
|
|
92
|
+
kwargs = {
|
|
93
|
+
"ecu": device.dlt_ecu or "ECU1",
|
|
94
|
+
"host": device.dlt_host,
|
|
95
|
+
"port": device.dlt_port,
|
|
96
|
+
"output_dir": str(output_dir),
|
|
97
|
+
}
|
|
98
|
+
elif driver_name == "fake_log":
|
|
99
|
+
kwargs = {"output_dir": str(output_dir)}
|
|
100
|
+
else:
|
|
101
|
+
kwargs = {}
|
|
102
|
+
|
|
103
|
+
# Drop unset values so each driver's own defaults apply, then layer extras.
|
|
104
|
+
kwargs = {key: value for key, value in kwargs.items() if value is not None}
|
|
105
|
+
kwargs.update(device.extra_options())
|
|
106
|
+
return kwargs
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def build_devices(
|
|
110
|
+
bench: BenchConfig,
|
|
111
|
+
*,
|
|
112
|
+
output_dir: str | Path,
|
|
113
|
+
factory: Callable[..., Any] = create_driver,
|
|
114
|
+
) -> Devices:
|
|
115
|
+
"""Instantiate every device on a bench and bundle them for the engine.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
bench: The bench configuration.
|
|
119
|
+
output_dir: Directory where camera/DLT drivers write their artifacts.
|
|
120
|
+
factory: Driver factory (injectable for tests; defaults to the registry).
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
A :class:`Devices` bundle. The first power/camera/log device fills the
|
|
124
|
+
matching capability slot; every command-capable device is addressable by
|
|
125
|
+
its role.
|
|
126
|
+
|
|
127
|
+
Raises:
|
|
128
|
+
ConfigError: If a device resolves to a controller of an unknown kind.
|
|
129
|
+
"""
|
|
130
|
+
out = Path(output_dir)
|
|
131
|
+
roles: dict[str, DeviceController] = {}
|
|
132
|
+
power: PowerController | None = None
|
|
133
|
+
camera: CameraController | None = None
|
|
134
|
+
log: LogController | None = None
|
|
135
|
+
|
|
136
|
+
for role, device in bench.devices.items():
|
|
137
|
+
name = driver_name_for(device)
|
|
138
|
+
controller = factory(name, **_driver_kwargs(device, name, out))
|
|
139
|
+
if isinstance(controller, PowerController):
|
|
140
|
+
power = power or controller
|
|
141
|
+
elif isinstance(controller, LogController):
|
|
142
|
+
log = log or controller
|
|
143
|
+
elif isinstance(controller, CameraController):
|
|
144
|
+
camera = camera or controller
|
|
145
|
+
elif isinstance(controller, DeviceController):
|
|
146
|
+
roles[role] = controller
|
|
147
|
+
else:
|
|
148
|
+
raise ConfigError(
|
|
149
|
+
f"device '{role}' resolved to driver '{name}', which is not a known controller type"
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
return Devices(roles=roles, power=power, camera=camera, log=log)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Layered TOML configuration loading and bench file I/O.
|
|
2
|
+
|
|
3
|
+
Configuration is layered ``global -> project -> bench -> user`` (later layers win),
|
|
4
|
+
all in TOML. The standard library reads TOML (``tomllib``); ``tomli_w`` writes the
|
|
5
|
+
auto-managed bench files. Secrets never live in these files — they come from
|
|
6
|
+
environment variables or the OS keyring (see :mod:`maestro_core.config.settings`).
|
|
7
|
+
|
|
8
|
+
Author:
|
|
9
|
+
Naveen Daniel Kennedy <nvnkennedy@gmail.com>
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import tomllib
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import tomli_w
|
|
19
|
+
|
|
20
|
+
from maestro_core.config.models import BenchConfig, DeviceConfig
|
|
21
|
+
from maestro_core.errors import ConfigError
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load_toml(path: str | Path) -> dict[str, Any]:
|
|
25
|
+
"""Read a TOML file into a dict.
|
|
26
|
+
|
|
27
|
+
Raises:
|
|
28
|
+
ConfigError: If the file cannot be parsed.
|
|
29
|
+
"""
|
|
30
|
+
try:
|
|
31
|
+
with Path(path).open("rb") as handle:
|
|
32
|
+
return tomllib.load(handle)
|
|
33
|
+
except (OSError, tomllib.TOMLDecodeError) as exc:
|
|
34
|
+
raise ConfigError(f"could not read TOML file {path}: {exc}") from exc
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
|
|
38
|
+
"""Recursively merge ``override`` onto ``base`` (override wins on conflict)."""
|
|
39
|
+
result = dict(base)
|
|
40
|
+
for key, value in override.items():
|
|
41
|
+
existing = result.get(key)
|
|
42
|
+
if isinstance(existing, dict) and isinstance(value, dict):
|
|
43
|
+
result[key] = deep_merge(existing, value)
|
|
44
|
+
else:
|
|
45
|
+
result[key] = value
|
|
46
|
+
return result
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def load_layered(paths: list[str | Path]) -> dict[str, Any]:
|
|
50
|
+
"""Merge a list of TOML files in order (later files override earlier ones).
|
|
51
|
+
|
|
52
|
+
Missing files are skipped, so a partial set of layers is fine.
|
|
53
|
+
"""
|
|
54
|
+
merged: dict[str, Any] = {}
|
|
55
|
+
for path in paths:
|
|
56
|
+
candidate = Path(path)
|
|
57
|
+
if candidate.exists():
|
|
58
|
+
merged = deep_merge(merged, load_toml(candidate))
|
|
59
|
+
return merged
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def load_bench_toml(path: str | Path) -> BenchConfig:
|
|
63
|
+
"""Load a bench file (``[bench]`` table + ``[devices.<role>]`` tables).
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
path: The bench TOML file.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
The parsed :class:`BenchConfig`.
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
ConfigError: If the file is missing or malformed.
|
|
73
|
+
"""
|
|
74
|
+
data = load_toml(path)
|
|
75
|
+
bench_table = dict(data.get("bench", {}))
|
|
76
|
+
device_tables = data.get("devices", {})
|
|
77
|
+
if not isinstance(device_tables, dict):
|
|
78
|
+
raise ConfigError(f"{path}: [devices] must be a table of device entries")
|
|
79
|
+
devices = {role: DeviceConfig(**cfg) for role, cfg in device_tables.items()}
|
|
80
|
+
try:
|
|
81
|
+
return BenchConfig(**bench_table, devices=devices)
|
|
82
|
+
except Exception as exc: # pydantic ValidationError -> typed ConfigError
|
|
83
|
+
raise ConfigError(f"{path}: invalid bench configuration: {exc}") from exc
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def write_bench_toml(bench: BenchConfig, path: str | Path) -> Path:
|
|
87
|
+
"""Write a bench config to TOML (the format detection produces).
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
bench: The bench configuration to serialise.
|
|
91
|
+
path: Destination file (parent directories are created).
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
The path that was written.
|
|
95
|
+
"""
|
|
96
|
+
bench_table = bench.model_dump(exclude_none=True, exclude={"devices"})
|
|
97
|
+
bench_table = {key: value for key, value in bench_table.items() if value != ""}
|
|
98
|
+
devices_table = {
|
|
99
|
+
role: device.model_dump(exclude_none=True, exclude_defaults=False)
|
|
100
|
+
for role, device in bench.devices.items()
|
|
101
|
+
}
|
|
102
|
+
# Drop empty 'options' dicts so the file stays tidy.
|
|
103
|
+
for entry in devices_table.values():
|
|
104
|
+
if entry.get("options") == {}:
|
|
105
|
+
entry.pop("options", None)
|
|
106
|
+
document: dict[str, Any] = {"bench": bench_table, "devices": devices_table}
|
|
107
|
+
target = Path(path)
|
|
108
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
109
|
+
with target.open("wb") as handle:
|
|
110
|
+
tomli_w.dump(document, handle)
|
|
111
|
+
return target
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Configuration models — benches and devices.
|
|
2
|
+
|
|
3
|
+
A bench TOML (auto-managed by "Detect devices") describes a test rig: the host,
|
|
4
|
+
its RDP target, and one entry per attached device. These models parse that file
|
|
5
|
+
and are the input to :func:`maestro_core.config.devices.build_devices`, which
|
|
6
|
+
turns them into live driver instances.
|
|
7
|
+
|
|
8
|
+
Unknown keys are accepted (``extra="allow"``) so a newer detector or a hand-edited
|
|
9
|
+
file can carry fields this version does not know about; they are forwarded to the
|
|
10
|
+
driver as extra keyword arguments.
|
|
11
|
+
|
|
12
|
+
Author:
|
|
13
|
+
Naveen Daniel Kennedy <nvnkennedy@gmail.com>
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DeviceConfig(BaseModel):
|
|
24
|
+
"""One device on a bench (an adb target, ssh host, camera, power, or DLT)."""
|
|
25
|
+
|
|
26
|
+
model_config = ConfigDict(extra="allow")
|
|
27
|
+
|
|
28
|
+
type: str = Field(default="linux", description="Device type or a driver name.")
|
|
29
|
+
driver: str | None = Field(default=None, description="Explicit driver name (overrides type).")
|
|
30
|
+
|
|
31
|
+
# SSH endpoint
|
|
32
|
+
ssh_host: str | None = None
|
|
33
|
+
ssh_user: str | None = None
|
|
34
|
+
ssh_port: int | None = None
|
|
35
|
+
ssh_key: str | None = None
|
|
36
|
+
ssh_use_stored: bool = False
|
|
37
|
+
ssh_service: str | None = None
|
|
38
|
+
ssh_domain: str | None = None
|
|
39
|
+
|
|
40
|
+
# ADB endpoint
|
|
41
|
+
adb_path: str | None = None
|
|
42
|
+
adb_serial: str | None = None
|
|
43
|
+
adb_host: str | None = None
|
|
44
|
+
adb_port: int | None = None
|
|
45
|
+
|
|
46
|
+
# Camera endpoint (both ``camera_*`` and the terse ``index``/``name`` are accepted)
|
|
47
|
+
camera_index: int | None = None
|
|
48
|
+
camera_name: str | None = None
|
|
49
|
+
index: int | None = None
|
|
50
|
+
name: str | None = None
|
|
51
|
+
|
|
52
|
+
# DLT endpoint
|
|
53
|
+
dlt_ecu: str | None = None
|
|
54
|
+
dlt_host: str | None = None
|
|
55
|
+
dlt_port: int | None = None
|
|
56
|
+
|
|
57
|
+
# Power endpoint
|
|
58
|
+
power_channel: str | None = None
|
|
59
|
+
com_port: str | None = None
|
|
60
|
+
script: str | None = None
|
|
61
|
+
python: str | None = None
|
|
62
|
+
|
|
63
|
+
# Generic
|
|
64
|
+
timeout: float | None = None
|
|
65
|
+
options: dict[str, Any] = Field(
|
|
66
|
+
default_factory=dict, description="Extra keyword arguments passed to the driver."
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def extra_options(self) -> dict[str, Any]:
|
|
70
|
+
"""Return ``options`` merged with any unknown (``extra``) keys."""
|
|
71
|
+
merged = dict(self.options)
|
|
72
|
+
merged.update(self.__pydantic_extra__ or {})
|
|
73
|
+
return merged
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class BenchConfig(BaseModel):
|
|
77
|
+
"""A test bench: a host with a set of attached devices."""
|
|
78
|
+
|
|
79
|
+
model_config = ConfigDict(extra="forbid")
|
|
80
|
+
|
|
81
|
+
name: str = Field(min_length=1)
|
|
82
|
+
host_os: str = "windows"
|
|
83
|
+
rdp: str | None = Field(default=None, description="RDP target host/IP for the bench.")
|
|
84
|
+
project: str | None = None
|
|
85
|
+
notes: str = ""
|
|
86
|
+
devices: dict[str, DeviceConfig] = Field(default_factory=dict)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Typed, environment-driven settings.
|
|
2
|
+
|
|
3
|
+
These are the knobs that change per *installation* (where data lives, which
|
|
4
|
+
database to use) rather than per *bench* (which is the TOML files). Values come
|
|
5
|
+
from ``MAESTRO_*`` environment variables or an optional ``.env`` file. Secrets
|
|
6
|
+
(SMTP password, Xray token, …) are read from the environment only — never written
|
|
7
|
+
to a config file (brief §13).
|
|
8
|
+
|
|
9
|
+
Author:
|
|
10
|
+
Naveen Daniel Kennedy <nvnkennedy@gmail.com>
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from pydantic import Field
|
|
18
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _default_home() -> Path:
|
|
22
|
+
"""Return the default MAESTRO data directory (``~/.maestro``)."""
|
|
23
|
+
return Path.home() / ".maestro"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MaestroSettings(BaseSettings):
|
|
27
|
+
"""Installation-level settings, configurable via ``MAESTRO_*`` env vars."""
|
|
28
|
+
|
|
29
|
+
model_config = SettingsConfigDict(
|
|
30
|
+
env_prefix="MAESTRO_", env_file=".env", env_file_encoding="utf-8", extra="ignore"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
home: Path = Field(default_factory=_default_home, description="Base data directory.")
|
|
34
|
+
config_dir: Path = Field(default=Path("config"), description="Where TOML config lives.")
|
|
35
|
+
runs_dir: Path = Field(
|
|
36
|
+
default_factory=lambda: _default_home() / "runs", description="Where run artifacts go."
|
|
37
|
+
)
|
|
38
|
+
user_steps_dir: Path | None = Field(
|
|
39
|
+
default=None, description="Optional folder of drop-in step scripts."
|
|
40
|
+
)
|
|
41
|
+
database_url: str = Field(
|
|
42
|
+
default="sqlite:///./maestro.db", description="SQLAlchemy database URL."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def global_toml(self) -> Path:
|
|
46
|
+
"""Return the path to the global config TOML."""
|
|
47
|
+
return self.config_dir / "global.toml"
|
|
48
|
+
|
|
49
|
+
def project_toml(self, project: str) -> Path:
|
|
50
|
+
"""Return the path to a project's config TOML."""
|
|
51
|
+
return self.config_dir / "projects" / f"{project}.toml"
|
|
52
|
+
|
|
53
|
+
def bench_toml(self, bench: str) -> Path:
|
|
54
|
+
"""Return the path to a bench's config TOML."""
|
|
55
|
+
return self.config_dir / "benches" / f"{bench}.toml"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_settings() -> MaestroSettings:
|
|
59
|
+
"""Build a fresh :class:`MaestroSettings` from the current environment."""
|
|
60
|
+
return MaestroSettings()
|