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.
Files changed (97) hide show
  1. maestro_core/__init__.py +43 -0
  2. maestro_core/cli.py +180 -0
  3. maestro_core/config/__init__.py +32 -0
  4. maestro_core/config/devices.py +152 -0
  5. maestro_core/config/loader.py +111 -0
  6. maestro_core/config/models.py +86 -0
  7. maestro_core/config/settings.py +60 -0
  8. maestro_core/detect/__init__.py +31 -0
  9. maestro_core/detect/adb_detector.py +67 -0
  10. maestro_core/detect/base.py +57 -0
  11. maestro_core/detect/camera_detector.py +72 -0
  12. maestro_core/detect/registry.py +64 -0
  13. maestro_core/detect/serial_detector.py +42 -0
  14. maestro_core/detect/ssh_detector.py +83 -0
  15. maestro_core/drivers/__init__.py +59 -0
  16. maestro_core/drivers/_cli.py +61 -0
  17. maestro_core/drivers/adb_turboadb.py +138 -0
  18. maestro_core/drivers/base.py +333 -0
  19. maestro_core/drivers/dlt.py +116 -0
  20. maestro_core/drivers/fake.py +228 -0
  21. maestro_core/drivers/power.py +92 -0
  22. maestro_core/drivers/registry.py +69 -0
  23. maestro_core/drivers/ssh_turbossh.py +113 -0
  24. maestro_core/drivers/webcam.py +96 -0
  25. maestro_core/endurance/__init__.py +20 -0
  26. maestro_core/endurance/health.py +72 -0
  27. maestro_core/endurance/loop.py +97 -0
  28. maestro_core/endurance/rollup.py +65 -0
  29. maestro_core/endurance/stop.py +97 -0
  30. maestro_core/engine/__init__.py +30 -0
  31. maestro_core/engine/_pytest_scenario.py +29 -0
  32. maestro_core/engine/expect.py +53 -0
  33. maestro_core/engine/plugin.py +112 -0
  34. maestro_core/engine/runner.py +435 -0
  35. maestro_core/errors.py +63 -0
  36. maestro_core/plugins.py +88 -0
  37. maestro_core/proc.py +154 -0
  38. maestro_core/py.typed +0 -0
  39. maestro_core/reporting/__init__.py +45 -0
  40. maestro_core/reporting/junit.py +108 -0
  41. maestro_core/reporting/records.py +146 -0
  42. maestro_core/reporting/reporter.py +86 -0
  43. maestro_core/reporting/sink.py +124 -0
  44. maestro_core/scenario/__init__.py +84 -0
  45. maestro_core/scenario/graph.py +318 -0
  46. maestro_core/scenario/io.py +102 -0
  47. maestro_core/scenario/models.py +276 -0
  48. maestro_core/scenario/schema.py +41 -0
  49. maestro_core/scenario/validation.py +247 -0
  50. maestro_core/steps/__init__.py +55 -0
  51. maestro_core/steps/builtins.py +270 -0
  52. maestro_core/steps/context.py +125 -0
  53. maestro_core/steps/discovery.py +90 -0
  54. maestro_core/steps/registry.py +245 -0
  55. maestro_core/steps/script.py +128 -0
  56. maestro_core/timeutil.py +87 -0
  57. maestro_framework-0.1.0.dist-info/METADATA +99 -0
  58. maestro_framework-0.1.0.dist-info/RECORD +97 -0
  59. maestro_framework-0.1.0.dist-info/WHEEL +4 -0
  60. maestro_framework-0.1.0.dist-info/entry_points.txt +28 -0
  61. maestro_server/__init__.py +16 -0
  62. maestro_server/api/__init__.py +19 -0
  63. maestro_server/api/benches.py +88 -0
  64. maestro_server/api/crud_router.py +179 -0
  65. maestro_server/api/deps.py +42 -0
  66. maestro_server/api/misc.py +132 -0
  67. maestro_server/api/resources.py +331 -0
  68. maestro_server/api/runs.py +252 -0
  69. maestro_server/auth.py +61 -0
  70. maestro_server/cli.py +46 -0
  71. maestro_server/db/__init__.py +48 -0
  72. maestro_server/db/base.py +53 -0
  73. maestro_server/db/models.py +249 -0
  74. maestro_server/db/session.py +52 -0
  75. maestro_server/integrations/__init__.py +14 -0
  76. maestro_server/integrations/base.py +38 -0
  77. maestro_server/integrations/dispatch.py +43 -0
  78. maestro_server/integrations/email.py +78 -0
  79. maestro_server/integrations/jira_xray.py +57 -0
  80. maestro_server/main.py +124 -0
  81. maestro_server/scheduler.py +183 -0
  82. maestro_server/schemas.py +520 -0
  83. maestro_server/services/__init__.py +14 -0
  84. maestro_server/services/bench.py +40 -0
  85. maestro_server/services/crud.py +125 -0
  86. maestro_server/services/detection.py +102 -0
  87. maestro_server/services/run_trigger.py +137 -0
  88. maestro_server/services/runs.py +300 -0
  89. maestro_server/settings.py +75 -0
  90. maestro_server/web/assets/index-BQIDZnuk.js +275 -0
  91. maestro_server/web/assets/index-Cee9fdIV.css +1 -0
  92. maestro_server/web/favicon.svg +15 -0
  93. maestro_server/web/index.html +15 -0
  94. maestro_tools/__init__.py +17 -0
  95. maestro_tools/cli.py +82 -0
  96. maestro_tools/generator.py +34 -0
  97. maestro_tools/invoke.py +110 -0
@@ -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()