tan-cli 0.7.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 (330) hide show
  1. tan/__init__.py +0 -0
  2. tan/__main__.py +120 -0
  3. tan/cli.py +939 -0
  4. tan/command_table.py +265 -0
  5. tan/commands/__init__.py +8 -0
  6. tan/commands/bootstrap_cmd.py +4856 -0
  7. tan/commands/bootstrap_patches.py +164 -0
  8. tan/commands/bootstrap_toolchain_fetch.py +155 -0
  9. tan/commands/bootstrap_toolchain_pin.py +36 -0
  10. tan/commands/build/__init__.py +1 -0
  11. tan/commands/build/configure_inputs.py +277 -0
  12. tan/commands/build/execute.py +1852 -0
  13. tan/commands/build/host_python.py +59 -0
  14. tan/commands/build/link_stale.py +78 -0
  15. tan/commands/build/manifest.py +428 -0
  16. tan/commands/build/materialise.py +81 -0
  17. tan/commands/build/token_substitution.py +381 -0
  18. tan/commands/build/toolchain.py +482 -0
  19. tan/commands/build/user_defines_wipe.py +115 -0
  20. tan/commands/build/workspace_patches.py +65 -0
  21. tan/commands/build_cmd.py +2236 -0
  22. tan/commands/build_output.py +331 -0
  23. tan/commands/clean_cmd.py +1183 -0
  24. tan/commands/completion_cmd.py +786 -0
  25. tan/commands/debug_config_cmd.py +2425 -0
  26. tan/commands/diff_cmd.py +940 -0
  27. tan/commands/doctor_cmd.py +4767 -0
  28. tan/commands/examples_cmd.py +644 -0
  29. tan/commands/explain_cmd.py +1319 -0
  30. tan/commands/faultdecode_cmd.py +931 -0
  31. tan/commands/flash_atoc_guard.py +240 -0
  32. tan/commands/flash_cmd.py +5731 -0
  33. tan/commands/flash_mram_guard.py +150 -0
  34. tan/commands/flash_ram.py +578 -0
  35. tan/commands/flash_raw.py +583 -0
  36. tan/commands/generate_cmd.py +1456 -0
  37. tan/commands/image_cmd.py +753 -0
  38. tan/commands/init_cmd.py +1740 -0
  39. tan/commands/inspect_cmd.py +377 -0
  40. tan/commands/kconfig_cmd.py +734 -0
  41. tan/commands/model_cmd.py +1514 -0
  42. tan/commands/model_device_cmd.py +351 -0
  43. tan/commands/model_device_live.py +234 -0
  44. tan/commands/model_host_cmd.py +430 -0
  45. tan/commands/model_zoo_cmd.py +363 -0
  46. tan/commands/monitor_actions.py +125 -0
  47. tan/commands/monitor_cmd.py +626 -0
  48. tan/commands/monitor_session.py +505 -0
  49. tan/commands/new_som_cmd.py +1522 -0
  50. tan/commands/pinmux_cmd.py +568 -0
  51. tan/commands/presets_cmd.py +1123 -0
  52. tan/commands/probe_cmd.py +421 -0
  53. tan/commands/reset_cmd.py +354 -0
  54. tan/commands/reset_confirm.py +168 -0
  55. tan/commands/run_cmd.py +637 -0
  56. tan/commands/scaffold_cmd.py +490 -0
  57. tan/commands/sdk_cmd.py +1723 -0
  58. tan/commands/size_cmd.py +1020 -0
  59. tan/commands/support_bundle_cmd.py +1073 -0
  60. tan/commands/trace_cmd.py +341 -0
  61. tan/commands/validate_cmd.py +2009 -0
  62. tan/commands/west_forward_cmd.py +736 -0
  63. tan/commands/workspace_patch_check.py +296 -0
  64. tan/core/__init__.py +1 -0
  65. tan/core/alp_conf_pregen.py +100 -0
  66. tan/core/artifact_provenance.py +240 -0
  67. tan/core/atoc_guard.py +276 -0
  68. tan/core/atoc_guard_messages.py +232 -0
  69. tan/core/atoc_replacement.py +118 -0
  70. tan/core/atomic_write.py +178 -0
  71. tan/core/board_context.py +351 -0
  72. tan/core/board_diagnostic.py +111 -0
  73. tan/core/board_files.py +221 -0
  74. tan/core/board_validator.py +542 -0
  75. tan/core/board_validator_compat.py +196 -0
  76. tan/core/board_validator_run.py +129 -0
  77. tan/core/board_validator_schema.py +186 -0
  78. tan/core/board_validator_skew.py +46 -0
  79. tan/core/board_yaml_edit.py +295 -0
  80. tan/core/board_yaml_pos.py +118 -0
  81. tan/core/bootstrap.py +2572 -0
  82. tan/core/build_plan.py +625 -0
  83. tan/core/consent.py +104 -0
  84. tan/core/console_filter.py +245 -0
  85. tan/core/debug_launch.py +1803 -0
  86. tan/core/devicetree_lint.py +168 -0
  87. tan/core/dir_removal.py +229 -0
  88. tan/core/doctor_git.py +172 -0
  89. tan/core/doctor_libraries.py +253 -0
  90. tan/core/doctor_render.py +185 -0
  91. tan/core/doctor_scope.py +66 -0
  92. tan/core/doctor_setools.py +237 -0
  93. tan/core/doctor_stale.py +180 -0
  94. tan/core/document_guards.py +723 -0
  95. tan/core/dp_id.py +97 -0
  96. tan/core/elf_load.py +63 -0
  97. tan/core/error_catalog.py +171 -0
  98. tan/core/example_catalog.py +499 -0
  99. tan/core/example_facets.py +151 -0
  100. tan/core/faultdecode.py +521 -0
  101. tan/core/flash_plan.py +2501 -0
  102. tan/core/flow_d_report.py +381 -0
  103. tan/core/flow_d_uptime.py +164 -0
  104. tan/core/fs_confine.py +130 -0
  105. tan/core/global_flags.py +230 -0
  106. tan/core/host_python.py +210 -0
  107. tan/core/image_bundle.py +247 -0
  108. tan/core/inert.py +147 -0
  109. tan/core/jlink_binary.py +128 -0
  110. tan/core/jlink_probe.py +481 -0
  111. tan/core/jsonc_splice.py +404 -0
  112. tan/core/launch_provenance.py +257 -0
  113. tan/core/link_refusal.py +76 -0
  114. tan/core/metadata_schema.py +376 -0
  115. tan/core/model_check.py +227 -0
  116. tan/core/model_device.py +449 -0
  117. tan/core/model_doctor.py +198 -0
  118. tan/core/model_host.py +48 -0
  119. tan/core/model_list.py +112 -0
  120. tan/core/model_zoo.py +353 -0
  121. tan/core/module_template.py +340 -0
  122. tan/core/mram_link.py +87 -0
  123. tan/core/new_som_ospi.py +94 -0
  124. tan/core/os_class.py +159 -0
  125. tan/core/patch_paths.py +87 -0
  126. tan/core/pending.py +40 -0
  127. tan/core/plain_zephyr_plan.py +138 -0
  128. tan/core/plan_exec.py +373 -0
  129. tan/core/plan_tokens.py +600 -0
  130. tan/core/probe.py +155 -0
  131. tan/core/probe_plan.py +237 -0
  132. tan/core/proxy.py +168 -0
  133. tan/core/publish.py +31 -0
  134. tan/core/python_floor.py +178 -0
  135. tan/core/ram_run.py +694 -0
  136. tan/core/ram_watch.py +231 -0
  137. tan/core/raw_write.py +160 -0
  138. tan/core/reset_plan.py +56 -0
  139. tan/core/run.py +161 -0
  140. tan/core/scaffold.py +2203 -0
  141. tan/core/scaffold_selftest_identity.py +225 -0
  142. tan/core/sdk_default_registry.py +565 -0
  143. tan/core/sdk_discovery.py +996 -0
  144. tan/core/sdk_removal.py +465 -0
  145. tan/core/serial_actions.py +175 -0
  146. tan/core/serial_capture.py +185 -0
  147. tan/core/serial_url.py +45 -0
  148. tan/core/setools.py +608 -0
  149. tan/core/setools_scratch.py +243 -0
  150. tan/core/shapes.py +194 -0
  151. tan/core/size.py +610 -0
  152. tan/core/som_buildability.py +204 -0
  153. tan/core/som_schema_version.py +62 -0
  154. tan/core/subprocess_env.py +201 -0
  155. tan/core/sysbuild_image.py +94 -0
  156. tan/core/system_manifest.py +827 -0
  157. tan/core/text_layout.py +73 -0
  158. tan/core/timestamp.py +182 -0
  159. tan/core/tool_lookup.py +383 -0
  160. tan/core/toolchain_pin.py +117 -0
  161. tan/core/toolchain_provision.py +1224 -0
  162. tan/core/trusted_path.py +134 -0
  163. tan/core/uboot_breakin.py +154 -0
  164. tan/core/uri_reference.py +415 -0
  165. tan/core/user_defines.py +152 -0
  166. tan/core/venv.py +332 -0
  167. tan/core/west_patches.py +143 -0
  168. tan/core/west_workspace_refusal.py +159 -0
  169. tan/core/zephyr_env.py +124 -0
  170. tan/env.py +185 -0
  171. tan/envelope.py +647 -0
  172. tan/exit_codes.py +12 -0
  173. tan/lazy_typer.py +185 -0
  174. tan/model/__init__.py +2 -0
  175. tan/model/_gen_fixture.py +132 -0
  176. tan/model/adapters/__init__.py +116 -0
  177. tan/model/adapters/cpu.py +26 -0
  178. tan/model/adapters/deepx.py +94 -0
  179. tan/model/adapters/drpai.py +242 -0
  180. tan/model/adapters/ethos_u.py +951 -0
  181. tan/model/adapters/executorch.py +55 -0
  182. tan/model/analyze.py +694 -0
  183. tan/model/build.py +304 -0
  184. tan/model/check.py +682 -0
  185. tan/model/manifest.py +625 -0
  186. tan/model/measure.py +216 -0
  187. tan/model/npu_ops_validate.py +130 -0
  188. tan/model/package.py +125 -0
  189. tan/model/perf.py +635 -0
  190. tan/model/perf_apply.py +721 -0
  191. tan/model/prep.py +193 -0
  192. tan/model/sram_fit.py +502 -0
  193. tan/model/targets.py +374 -0
  194. tan/model/tensorio.py +287 -0
  195. tan/net.py +73 -0
  196. tan/output_format.py +213 -0
  197. tan/planner/__init__.py +219 -0
  198. tan/planner/aperture.py +202 -0
  199. tan/planner/buildplan.py +1092 -0
  200. tan/planner/camera_owner.py +192 -0
  201. tan/planner/cameras.py +132 -0
  202. tan/planner/carveout.py +755 -0
  203. tan/planner/cli.py +274 -0
  204. tan/planner/headers.py +315 -0
  205. tan/planner/kconfig.py +2435 -0
  206. tan/planner/kconfig_symbols.py +473 -0
  207. tan/planner/libraries.py +677 -0
  208. tan/planner/link_target.py +237 -0
  209. tan/planner/linux_ownership.py +216 -0
  210. tan/planner/loader.py +1525 -0
  211. tan/planner/manifest.py +116 -0
  212. tan/planner/memory.py +161 -0
  213. tan/planner/memregion.py +56 -0
  214. tan/planner/models.py +533 -0
  215. tan/planner/orchestrator.py +874 -0
  216. tan/planner/ownership.py +203 -0
  217. tan/planner/partition.py +1160 -0
  218. tan/planner/paths.py +32 -0
  219. tan/planner/plain_slice.py +139 -0
  220. tan/planner/project_emit/__init__.py +60 -0
  221. tan/planner/project_emit/bom_netlist.py +375 -0
  222. tan/planner/project_emit/dts.py +580 -0
  223. tan/planner/project_emit/hw_info.py +221 -0
  224. tan/planner/project_emit/native_sim.py +136 -0
  225. tan/planner/project_emit/west_libs.py +213 -0
  226. tan/planner/project_loader.py +264 -0
  227. tan/planner/sdk_capability.py +203 -0
  228. tan/planner/sdk_compat.py +395 -0
  229. tan/planner/secure.py +350 -0
  230. tan/planner/slugs.py +394 -0
  231. tan/planner/som_metadata.py +325 -0
  232. tan/planner/strict_loaders.py +131 -0
  233. tan/planner/template.py +1037 -0
  234. tan/planner/template_pins.py +535 -0
  235. tan/planner/template_rewrite.py +714 -0
  236. tan/planner/topology.py +208 -0
  237. tan/planner/validate.py +365 -0
  238. tan/planner/whole_device_alias.py +67 -0
  239. tan/planner/zephyr_board.py +3510 -0
  240. tan/planner_cli.py +97 -0
  241. tan/planner_emit.py +617 -0
  242. tan/planner_root.py +119 -0
  243. tan/soc_ref.py +58 -0
  244. tan/templates/__init__.py +34 -0
  245. tan/templates/vendored/MANIFEST.md +1862 -0
  246. tan/templates/vendored/diagnostics/E1M-AEN801/CMakeLists.txt +8 -0
  247. tan/templates/vendored/diagnostics/E1M-AEN801/README.md +112 -0
  248. tan/templates/vendored/diagnostics/E1M-AEN801/board.yaml +38 -0
  249. tan/templates/vendored/diagnostics/E1M-AEN801/boards/native_sim_native_64.conf +11 -0
  250. tan/templates/vendored/diagnostics/E1M-AEN801/boards/native_sim_native_64.overlay +29 -0
  251. tan/templates/vendored/diagnostics/E1M-AEN801/prj.conf +5 -0
  252. tan/templates/vendored/diagnostics/E1M-AEN801/src/main.c +266 -0
  253. tan/templates/vendored/diagnostics/E1M-AEN801/testcase.yaml +53 -0
  254. tan/templates/vendored/diagnostics/E1M-V2N101/CMakeLists.txt +8 -0
  255. tan/templates/vendored/diagnostics/E1M-V2N101/README.md +112 -0
  256. tan/templates/vendored/diagnostics/E1M-V2N101/board.yaml +38 -0
  257. tan/templates/vendored/diagnostics/E1M-V2N101/boards/native_sim_native_64.conf +11 -0
  258. tan/templates/vendored/diagnostics/E1M-V2N101/boards/native_sim_native_64.overlay +29 -0
  259. tan/templates/vendored/diagnostics/E1M-V2N101/prj.conf +5 -0
  260. tan/templates/vendored/diagnostics/E1M-V2N101/src/main.c +266 -0
  261. tan/templates/vendored/diagnostics/E1M-V2N101/testcase.yaml +53 -0
  262. tan/templates/vendored/edge-ai/E1M-AEN801/CMakeLists.txt +10 -0
  263. tan/templates/vendored/edge-ai/E1M-AEN801/README.md +75 -0
  264. tan/templates/vendored/edge-ai/E1M-AEN801/board.yaml +38 -0
  265. tan/templates/vendored/edge-ai/E1M-AEN801/prj.conf +8 -0
  266. tan/templates/vendored/edge-ai/E1M-AEN801/src/cold_chain.c +371 -0
  267. tan/templates/vendored/edge-ai/E1M-AEN801/src/cold_chain.h +178 -0
  268. tan/templates/vendored/edge-ai/E1M-AEN801/src/main.c +373 -0
  269. tan/templates/vendored/edge-ai/E1M-AEN801/testcase.yaml +62 -0
  270. tan/templates/vendored/edge-ai/E1M-V2N101/CMakeLists.txt +10 -0
  271. tan/templates/vendored/edge-ai/E1M-V2N101/README.md +75 -0
  272. tan/templates/vendored/edge-ai/E1M-V2N101/board.yaml +37 -0
  273. tan/templates/vendored/edge-ai/E1M-V2N101/prj.conf +8 -0
  274. tan/templates/vendored/edge-ai/E1M-V2N101/src/cold_chain.c +371 -0
  275. tan/templates/vendored/edge-ai/E1M-V2N101/src/cold_chain.h +178 -0
  276. tan/templates/vendored/edge-ai/E1M-V2N101/src/main.c +373 -0
  277. tan/templates/vendored/edge-ai/E1M-V2N101/testcase.yaml +62 -0
  278. tan/templates/vendored/iot/E1M-AEN801/CMakeLists.txt +14 -0
  279. tan/templates/vendored/iot/E1M-AEN801/README.md +103 -0
  280. tan/templates/vendored/iot/E1M-AEN801/board.yaml +69 -0
  281. tan/templates/vendored/iot/E1M-AEN801/prj.conf +15 -0
  282. tan/templates/vendored/iot/E1M-AEN801/src/cc3501e_bridge.c +196 -0
  283. tan/templates/vendored/iot/E1M-AEN801/src/cc3501e_bridge.h +173 -0
  284. tan/templates/vendored/iot/E1M-AEN801/src/main.c +262 -0
  285. tan/templates/vendored/iot/E1M-AEN801/testcase.yaml +68 -0
  286. tan/templates/vendored/minimal/E1M-AEN801/CMakeLists.txt +8 -0
  287. tan/templates/vendored/minimal/E1M-AEN801/README.md +108 -0
  288. tan/templates/vendored/minimal/E1M-AEN801/board.yaml +32 -0
  289. tan/templates/vendored/minimal/E1M-AEN801/prj.conf +8 -0
  290. tan/templates/vendored/minimal/E1M-AEN801/src/main.c +111 -0
  291. tan/templates/vendored/minimal/E1M-AEN801/testcase.yaml +49 -0
  292. tan/templates/vendored/minimal/E1M-V2N101/CMakeLists.txt +8 -0
  293. tan/templates/vendored/minimal/E1M-V2N101/README.md +108 -0
  294. tan/templates/vendored/minimal/E1M-V2N101/board.yaml +32 -0
  295. tan/templates/vendored/minimal/E1M-V2N101/prj.conf +8 -0
  296. tan/templates/vendored/minimal/E1M-V2N101/src/main.c +111 -0
  297. tan/templates/vendored/minimal/E1M-V2N101/testcase.yaml +49 -0
  298. tan/templates/vendored/multicore-mailbox/E1M-AEN801/CMakeLists.txt +8 -0
  299. tan/templates/vendored/multicore-mailbox/E1M-AEN801/README.md +120 -0
  300. tan/templates/vendored/multicore-mailbox/E1M-AEN801/board.yaml +57 -0
  301. tan/templates/vendored/multicore-mailbox/E1M-AEN801/boards/native_sim_native_64.overlay +40 -0
  302. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/CMakeLists.txt +13 -0
  303. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/main.c +171 -0
  304. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/prj.conf +17 -0
  305. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/testcase.yaml +23 -0
  306. tan/templates/vendored/multicore-mailbox/E1M-AEN801/prj.conf +22 -0
  307. tan/templates/vendored/multicore-mailbox/E1M-AEN801/src/main.c +185 -0
  308. tan/templates/vendored/multicore-mailbox/E1M-AEN801/testcase.yaml +26 -0
  309. tan/templates/vendored/sensor/E1M-AEN801/CMakeLists.txt +8 -0
  310. tan/templates/vendored/sensor/E1M-AEN801/README.md +112 -0
  311. tan/templates/vendored/sensor/E1M-AEN801/board.yaml +57 -0
  312. tan/templates/vendored/sensor/E1M-AEN801/boards/native_sim_native_64.conf +10 -0
  313. tan/templates/vendored/sensor/E1M-AEN801/boards/native_sim_native_64.overlay +28 -0
  314. tan/templates/vendored/sensor/E1M-AEN801/prj.conf +5 -0
  315. tan/templates/vendored/sensor/E1M-AEN801/src/main.c +186 -0
  316. tan/templates/vendored/sensor/E1M-AEN801/testcase.yaml +52 -0
  317. tan/templates/vendored/sensor/E1M-V2N101/CMakeLists.txt +8 -0
  318. tan/templates/vendored/sensor/E1M-V2N101/README.md +112 -0
  319. tan/templates/vendored/sensor/E1M-V2N101/board.yaml +57 -0
  320. tan/templates/vendored/sensor/E1M-V2N101/boards/native_sim_native_64.conf +10 -0
  321. tan/templates/vendored/sensor/E1M-V2N101/boards/native_sim_native_64.overlay +28 -0
  322. tan/templates/vendored/sensor/E1M-V2N101/prj.conf +5 -0
  323. tan/templates/vendored/sensor/E1M-V2N101/src/main.c +186 -0
  324. tan/templates/vendored/sensor/E1M-V2N101/testcase.yaml +52 -0
  325. tan/version.py +102 -0
  326. tan_cli-0.7.0.dist-info/METADATA +125 -0
  327. tan_cli-0.7.0.dist-info/RECORD +330 -0
  328. tan_cli-0.7.0.dist-info/WHEEL +5 -0
  329. tan_cli-0.7.0.dist-info/entry_points.txt +2 -0
  330. tan_cli-0.7.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,827 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """The ALP **system manifest** (`build/system-manifest.yaml`) -- the derived
3
+ projection of a `board.yaml` that `--emit system-manifest` writes
4
+ (`tan/planner/manifest.py`, relocated from alp-sdk's `alp_orchestrate`), and the
5
+ tool CONTRACT for a (possibly multi-image) project. `tan image` and `tan size` both read THIS instead of re-deriving
6
+ anything from `board.yaml` + the SoM presets.
7
+
8
+ Port of `crates/tan-core/src/system_manifest.rs` (read half) plus the two
9
+ path rules its consumers share.
10
+
11
+ TOLERANT READER (the stability policy negotiated in alp-sdk#106):
12
+ `schema_version` 1 is additive-only, so an unknown key is IGNORED, never
13
+ rejected -- a newer SDK must not break an older `tan`. What IS rejected is a
14
+ document the Rust `serde` model could not deserialise at all: a non-mapping
15
+ root, a missing `schema_version`, or a `slices[]`/`ipc[]`/`helper_mcus[]` entry
16
+ missing a field that is non-`Option` there. Those are hard errors in the
17
+ oracle, and a partial read here would let `tan` act on a manifest the shipped
18
+ binary refuses.
19
+
20
+ **Nothing here shells the SDK.** Every input is a file this project's own build
21
+ wrote under its build root; the alp-sdk checkout is never invoked. That is
22
+ invariant I-32 and its anti-pattern #22 in
23
+ `docs/superpowers/specs/2026-07-29-tan-port-invariants.md`: giving a command an
24
+ alp-sdk-checkout dependency it deliberately does not have is a regression no
25
+ gate catches. `size` does READ `<sdk>/metadata/**` for the memory budget (the
26
+ oracle does too, and a missing SDK is non-fatal there -- the budget resolves
27
+ `unknown`), but it never executes anything in it, and no hardware fact -- SKU,
28
+ address, pin name -- is spelled in this port. Those live in alp-sdk
29
+ `metadata/` (I-26).
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import math
35
+ import os
36
+ import re
37
+ from collections.abc import Sequence
38
+ from dataclasses import dataclass
39
+ from typing import Any
40
+
41
+ #: The schema major this CLI consumes. A different value is REFUSED rather than
42
+ #: silently mis-applied -- `SYSTEM_MANIFEST_SCHEMA_VERSION` in the Rust.
43
+ SYSTEM_MANIFEST_SCHEMA_VERSION = 1
44
+
45
+ #: The single input document, read from the build root.
46
+ MANIFEST_FILE = "system-manifest.yaml"
47
+
48
+
49
+ def manifest_candidates(build_root: str) -> list[str]:
50
+ """Where a consumer looks for the manifest given `--build-root X`:
51
+ `X/system-manifest.yaml` first (a root that IS the build dir), then
52
+ `X/build/system-manifest.yaml` -- where `tan build --build-root X` writes
53
+ it (tan-cli#1405). The same `X` therefore works for build and every
54
+ consumer."""
55
+ return [
56
+ os.path.join(build_root, MANIFEST_FILE),
57
+ os.path.join(build_root, "build", MANIFEST_FILE),
58
+ ]
59
+
60
+
61
+ def find_manifest(build_root: str, is_file=os.path.isfile) -> str:
62
+ """The first existing candidate, else the first (so the caller's
63
+ not-found/read error names the primary path)."""
64
+ candidates = manifest_candidates(build_root)
65
+ for candidate in candidates:
66
+ if is_file(candidate):
67
+ return candidate
68
+ return candidates[0]
69
+
70
+
71
+ def effective_build_root(build_root: str, is_file=os.path.isfile) -> str:
72
+ """The directory the manifest was actually found in. Every relative
73
+ artefact path in the manifest (and the slice build-dir fallbacks) is
74
+ relative to THIS, so for `--build-root X` with the nested spelling it is
75
+ `X/build`, not `X` (tan-cli#1405). With no manifest present it is `X`."""
76
+ return os.path.dirname(find_manifest(build_root, is_file))
77
+
78
+ #: Subdirectory `west build` writes into when it is emitted with NO `-d` --
79
+ #: which is exactly how the SDK's build plan emits it (I-18). See
80
+ #: [`slice_elf_candidates`].
81
+ WEST_NESTED_DIR = "build"
82
+
83
+
84
+ class SystemManifestError(Exception):
85
+ """Why a system-manifest could not be consumed. `message` is the text the
86
+ oracle puts after `<path>: ` in its `*.manifest-invalid` issue."""
87
+
88
+ def __init__(self, message: str) -> None:
89
+ super().__init__(message)
90
+ self.message = message
91
+
92
+
93
+ def _parse_error(detail: str) -> SystemManifestError:
94
+ # Verbatim `SystemManifestError::Parse`'s `#[error(...)]` prefix. The
95
+ # `detail` tail comes from a different YAML library than the oracle's
96
+ # `serde_yaml`, so a syntax-error tail differs in wording; the shapes that
97
+ # matter to a reader (`missing field \`x\``, `invalid type: ...`) are
98
+ # reproduced below.
99
+ return SystemManifestError(f"system-manifest is not valid YAML: {detail}")
100
+
101
+
102
+ def _unsupported_version_error(found: Any) -> SystemManifestError:
103
+ # Verbatim `SystemManifestError::UnsupportedSchemaVersion`'s `#[error(...)]`.
104
+ return SystemManifestError(
105
+ f"unsupported system-manifest schema_version {found} "
106
+ f"(this CLI consumes v{SYSTEM_MANIFEST_SCHEMA_VERSION}); "
107
+ "upgrade the CLI or the SDK so the versions match"
108
+ )
109
+
110
+
111
+ def _serde_type_name(value: Any) -> str:
112
+ """serde's own spelling of an unexpected value's type, for the
113
+ `invalid type: <this>, expected struct SystemManifest` message."""
114
+ if value is None:
115
+ return "unit value"
116
+ if isinstance(value, bool):
117
+ return f"boolean `{str(value).lower()}`"
118
+ if isinstance(value, str):
119
+ return f'string "{value}"'
120
+ if isinstance(value, int):
121
+ return f"integer `{value}`"
122
+ if isinstance(value, float):
123
+ return f"floating point `{value}`"
124
+ if isinstance(value, (list, tuple)):
125
+ return "sequence"
126
+ return "map"
127
+
128
+
129
+ #: YAML **1.2 core-schema** implicit resolvers -- what serde_yaml applies, and
130
+ #: NOT what PyYAML's `SafeLoader` applies (that is YAML 1.1). Verified case by
131
+ #: case against the compiled binary; every row below is a document where the two
132
+ #: disagree and the difference reaches `data.hw_info` verbatim:
133
+ #: `yes`/`no`/`on`/`off` 1.1 boolean -> 1.2 plain STRING
134
+ #: `007` 1.1 int 7 -> 1.2 plain STRING
135
+ #: `1:30` 1.1 int 90 -> 1.2 plain STRING
136
+ #: `2024-01-01` 1.1 date -> 1.2 plain STRING (and a `date` is not
137
+ #: JSON-serializable, so PyYAML's answer
138
+ #: made `tan image` exit 3 where the
139
+ #: oracle exits 0)
140
+ #: `0o17` 1.1 string -> 1.2 int 15
141
+ #: `0x…` is an int in both.
142
+ #:
143
+ #: The INT row is serde_yaml's `parse_signed_int`, NOT the YAML 1.2 spec's core
144
+ #: schema regex, which is what it used to transcribe (tan-cli#499 defect 10).
145
+ #: serde_yaml strips a leading `+`/`-` BEFORE it tests the radix prefixes, and it
146
+ #: also accepts `0b`, so two more shapes are integers on the oracle and were
147
+ #: staying strings here -- measured against `tan 0.4.1`:
148
+ #: `0b1010` -> 10 `0b11` -> 3 `-0b11` -> -3 `+0b11` -> 3
149
+ #: `-0x1E` -> -30 `+0x1E` -> 30 `-0o17` -> -15 `+0o17` -> 15
150
+ #: and, unchanged, these stay STRINGS (also measured, and the reason the regex
151
+ #: has to stay the gate rather than deferring to PyYAML's `construct_yaml_int`,
152
+ #: which strips `_` and would resolve most of them):
153
+ #: `0B11` `0XA5` `0O17` `-0B11` `0x1_F` `0b1_0` `0o1_7` `1_000` `0_1`
154
+ #: `0x` `0b` `0o` `-0x` `0o8` `0xG` `0b2` `007` `+007` `++5`
155
+ #: The float regex is still the core schema's own.
156
+ _CORE_RESOLVERS = (
157
+ ("tag:yaml.org,2002:null", r"^(?:~|null|Null|NULL|)$", ["~", "n", "N", ""]),
158
+ (
159
+ "tag:yaml.org,2002:bool",
160
+ r"^(?:true|True|TRUE|false|False|FALSE)$",
161
+ list("tTfF"),
162
+ ),
163
+ (
164
+ "tag:yaml.org,2002:int",
165
+ r"^[-+]?(?:0|[1-9][0-9]*|0b[01]+|0o[0-7]+|0x[0-9a-fA-F]+)$",
166
+ list("-+0123456789"),
167
+ ),
168
+ (
169
+ "tag:yaml.org,2002:float",
170
+ r"^(?:[-+]?(?:\.[0-9]+|(?:0|[1-9][0-9]*)(?:\.[0-9]*)?)(?:[eE][-+]?[0-9]+)?"
171
+ r"|[-+]?\.(?:inf|Inf|INF)|\.(?:nan|NaN|NAN))$",
172
+ list("-+.0123456789"),
173
+ ),
174
+ )
175
+
176
+
177
+ #: The infinity/not-a-number spellings serde_yaml's `parse_f64` special-cases
178
+ #: BEFORE it ever tries a numeric parse, matched against the scalar with any
179
+ #: leading sign stripped. Only these become non-finite floats; see
180
+ #: [`_construct_core_float`].
181
+ _YAML_INF_SPELLINGS = (".inf", ".Inf", ".INF")
182
+ _YAML_NAN_SPELLINGS = (".nan", ".NaN", ".NAN")
183
+
184
+
185
+ def _construct_core_float(loader, node):
186
+ """A float-tagged plain scalar as serde_yaml's `parse_f64` resolves it --
187
+ which is NOT what `float()` does for an overflowing exponent (tan-cli#387).
188
+
189
+ serde_yaml accepts a parsed number only `if float.is_finite()`, and returns
190
+ `None` otherwise; a `serde_yaml::Value` then falls through to the next
191
+ candidate type and the scalar stays a STRING. So `soc_flash_mb: 1e400` is
192
+ the string `"1e400"` on the oracle, while PyYAML's own float constructor
193
+ resolves it to `inf`. That diverged twice over: the wrong TYPE in
194
+ `data.hw_info`, and then a value `json.dumps` wrote as the non-JSON literal
195
+ `Infinity` into both stdout and the persisted `bundle-manifest.json`.
196
+
197
+ The three EXPLICIT spellings are the deliberate exception -- `.inf`, `-.inf`
198
+ and `.nan` really do resolve to non-finite `f64` on the oracle. Turning them
199
+ into `null` is `serde_json`'s job at serialise time
200
+ (`tan.envelope.json_safe_floats`), not this loader's: doing it here would
201
+ emit `null` where the oracle emits the string `"1e400"`, and would lose the
202
+ distinction between the two cases entirely.
203
+ """
204
+ text = loader.construct_scalar(node)
205
+ unsigned = text[1:] if text[:1] in ("-", "+") else text
206
+ if unsigned in _YAML_INF_SPELLINGS:
207
+ return float("-inf") if text.startswith("-") else float("inf")
208
+ if unsigned in _YAML_NAN_SPELLINGS:
209
+ return float("nan")
210
+ try:
211
+ value = float(text)
212
+ except ValueError:
213
+ # Only reachable through an EXPLICIT `!!float` tag -- the implicit
214
+ # resolver's regex admits nothing `float()` refuses. The raw text is
215
+ # what serde_yaml is left holding too.
216
+ return text
217
+ return value if math.isfinite(value) else text
218
+
219
+
220
+ def _core_schema_loader(yaml_module):
221
+ """A `SafeLoader` resolving plain scalars by the YAML 1.2 CORE schema, so a
222
+ value carried verbatim into the bundle manifest matches what serde_yaml's
223
+ `Value` would have produced. See [`_CORE_RESOLVERS`]."""
224
+
225
+ class CoreSchemaLoader(yaml_module.SafeLoader):
226
+ pass
227
+
228
+ # A fresh dict on the subclass -- never a mutation of SafeLoader's own, which
229
+ # would silently change every other `safe_load` in the process.
230
+ CoreSchemaLoader.yaml_implicit_resolvers = {}
231
+ for tag, pattern, first in _CORE_RESOLVERS:
232
+ CoreSchemaLoader.add_implicit_resolver(tag, re.compile(pattern), first)
233
+ # `add_constructor` copies `yaml_constructors` onto the subclass first, so
234
+ # this is scoped the same way the resolvers above are.
235
+ CoreSchemaLoader.add_constructor("tag:yaml.org,2002:float", _construct_core_float)
236
+ return CoreSchemaLoader
237
+
238
+
239
+ def _literal_scalar_loader(yaml_module):
240
+ """A `SafeLoader` whose implicit tag resolution is switched OFF entirely, so
241
+ every plain scalar comes back as its RAW TEXT.
242
+
243
+ This is not a nicety. serde_yaml resolves a plain scalar against the TARGET
244
+ type, so a `String` field receives the scalar's raw text -- `os: ~` is the
245
+ string `"~"`, `build_dir: 007` is the path `007`, `sku: 0x10` is `"0x10"`.
246
+ Resolving first and stringifying after gives `"None"`, `"7"` and `"16"`: three
247
+ different values than the oracle for the same document, and `build_dir` /
248
+ `output_artefact` / `firmware_path` are PATHS.
249
+
250
+ An explicit tag (`!!int 5`) still resolves, which is why `_scalar_str` below
251
+ keeps its coercion arm.
252
+ """
253
+
254
+ class LiteralScalarLoader(yaml_module.SafeLoader):
255
+ pass
256
+
257
+ LiteralScalarLoader.yaml_implicit_resolvers = {}
258
+ return LiteralScalarLoader
259
+
260
+
261
+ def _import_yaml():
262
+ """PyYAML, or a [`SystemManifestError`] naming why it isn't available --
263
+ the one import guard [`load_yaml_document`] and [`serialize_system_manifest_raw`]
264
+ both funnel through, so a frozen `tan` built from a stale venv reports the
265
+ SAME coded envelope on either the read or the write path rather than one
266
+ of them tracebacking."""
267
+ try:
268
+ import yaml # noqa: PLC0415 (declared base dep; imported where needed)
269
+ except ImportError as err:
270
+ raise _parse_error(
271
+ f"no YAML parser available ({err}); install PyYAML "
272
+ "(`pip install pyyaml`) so tan can read system-manifest.yaml"
273
+ ) from err
274
+ return yaml
275
+
276
+
277
+ def load_yaml_document(text: str, *, literal_scalars: bool = False) -> Any:
278
+ """Parse a manifest document, with PyYAML's absence and every parse failure
279
+ funnelled into [`SystemManifestError`].
280
+
281
+ Two scalar policies, both required and neither PyYAML's default:
282
+ `literal_scalars` keeps every plain scalar as its raw text
283
+ ([`_literal_scalar_loader`]); otherwise the YAML 1.2 core schema applies
284
+ ([`_core_schema_loader`]). `yaml.safe_load` is used NOWHERE -- its YAML 1.1
285
+ resolution disagrees with serde_yaml on five shapes that reach the wire.
286
+
287
+ PyYAML IS a declared runtime dependency (`pyproject.toml` records why: a
288
+ frozen `tan` built without it has a `size`/`image` that can never read a
289
+ manifest). The import is still guarded, because a declared dependency is not
290
+ a present one -- a frozen artifact built from a stale venv, or a broken
291
+ source install, must still emit an envelope. The oracle cannot reach this
292
+ branch at all (serde_yaml is compiled in), so the code reused is
293
+ `*.manifest-invalid`: an existing spelling with an honest message beats
294
+ inventing one the extension has never heard of.
295
+ """
296
+ yaml = _import_yaml()
297
+ try:
298
+ loader = (
299
+ _literal_scalar_loader(yaml)
300
+ if literal_scalars
301
+ else _core_schema_loader(yaml)
302
+ )
303
+ return yaml.load(text, Loader=loader) # noqa: S506 -- SafeLoader-derived
304
+ except Exception as err: # noqa: BLE001 -- any PyYAML failure is bad input
305
+ # Includes ConstructorError for an unhashable YAML key (a sequence or
306
+ # mapping used as a key), which the oracle tolerates on read and only
307
+ # fails on later, at serialize time. Both ends emit a coded envelope;
308
+ # only the code differs, and neither ever tracebacks.
309
+ raise _parse_error(str(err).replace("\n", " ")) from err
310
+
311
+
312
+ # The string-typed fields of each modeled struct, split by whether the Rust models
313
+ # them `String` or `Option<String>`. Enumerated rather than sniffed because the
314
+ # CHECK is what makes this reader agree with serde on which documents are legal --
315
+ # and the SPLIT decides what a null means, which is not the same on both sides:
316
+ #
317
+ # `os: ~` `String` -> the string "~" (serde_yaml hands a
318
+ # `String` the scalar's RAW TEXT)
319
+ # `build_dir: ~` `Option<String>` -> ABSENT (`deserialize_option`
320
+ # sees the null first and yields `None`)
321
+ #
322
+ # Getting that backwards puts a slice's build dir at the literal path `~`, or
323
+ # loses a real `os` value. Both verified against the compiled binary.
324
+ #
325
+ # `flash_args` is a `serde_yaml::Value` in both structs and is deliberately absent
326
+ # from every tuple: it is opaque, and any shape is legal.
327
+ _SLICE_REQUIRED_STRINGS = ("core_id", "os", "status")
328
+ _SLICE_OPTIONAL_STRINGS = (
329
+ "app", "image", "machine", "board", "toolchain", "build_dir",
330
+ "output_artefact", "log_path", "reason", "flash_method",
331
+ )
332
+ _HELPER_REQUIRED_STRINGS = ("name", "chip")
333
+ _HELPER_OPTIONAL_STRINGS = ("firmware_path", "flash_method", "update_channel")
334
+ _IPC_REQUIRED_STRINGS = ("name", "kind")
335
+ _IPC_OPTIONAL_STRINGS = ("status", "reason")
336
+ _HW_INFO_REQUIRED_STRINGS = ("sku",)
337
+ _HW_INFO_OPTIONAL_STRINGS = ("som_hw_rev", "board_name", "board_hw_rev", "silicon")
338
+
339
+ #: Every root key the Rust models as a `Vec<_>`. Present-but-not-a-sequence is a
340
+ #: hard error, including `~`: unlike a string field, serde_yaml does NOT accept a
341
+ #: null there (verified against the binary -- `slices: ~` is refused while
342
+ #: `hw_info.sku: ~` is accepted).
343
+ _SEQUENCE_KEYS = ("slices", "ipc", "helper_mcus", "boot_order", "storage")
344
+
345
+
346
+ def _scalar_str(value: Any, field: str, where: str) -> str:
347
+ """A YAML scalar as the string serde_yaml produces for a `String` field.
348
+
349
+ Reached with the LITERAL tree (see [`_literal_scalar_loader`]), so a plain
350
+ scalar is already its raw text and this is a pass-through: `build_dir: 7` is
351
+ the path `7`, `os: ~` is the string `"~"`, `sku: 0x10` is `"0x10"` -- all
352
+ verified against the compiled binary, and all wrong if the resolved value is
353
+ stringified instead.
354
+
355
+ The coercion arm covers an EXPLICIT tag (`!!int 5`), which resolves even with
356
+ implicit resolution off. A sequence or mapping is refused, as serde does.
357
+ """
358
+ if isinstance(value, str):
359
+ return value
360
+ if value is None:
361
+ return ""
362
+ if isinstance(value, bool):
363
+ return "true" if value else "false"
364
+ if isinstance(value, (int, float)):
365
+ return str(value)
366
+ raise _parse_error(
367
+ f"{where}.{field}: invalid type: {_serde_type_name(value)}, expected a string"
368
+ )
369
+
370
+
371
+ def _coerce_strings(
372
+ entry: dict,
373
+ core: Any,
374
+ required: tuple[str, ...],
375
+ optional: tuple[str, ...],
376
+ where: str,
377
+ ) -> None:
378
+ """Validate + normalise every present string-typed field of `entry` in place.
379
+
380
+ `core` is the same mapping out of the CORE-schema tree, consulted for one
381
+ thing only: whether the scalar was a YAML null. An `Option<String>` field that
382
+ was is DELETED (serde yields `None`, i.e. absent); a plain `String` keeps its
383
+ raw text.
384
+ """
385
+ core = core if isinstance(core, dict) else {}
386
+ for field in required:
387
+ if field in entry:
388
+ entry[field] = _scalar_str(entry[field], field, where)
389
+ for field in optional:
390
+ if field not in entry:
391
+ continue
392
+ if core.get(field, "") is None:
393
+ del entry[field]
394
+ continue
395
+ entry[field] = _scalar_str(entry[field], field, where)
396
+
397
+
398
+ def _sequence(root: dict, key: str) -> list:
399
+ """A root `Vec<_>` field: absent is `[]`, present-but-not-a-sequence is a hard
400
+ error (`~` included)."""
401
+ if key not in root:
402
+ return []
403
+ raw = root[key]
404
+ if not isinstance(raw, list):
405
+ raise _parse_error(
406
+ f"{key}: invalid type: {_serde_type_name(raw)}, expected a sequence"
407
+ )
408
+ return raw
409
+
410
+
411
+ def _entries(
412
+ root: dict,
413
+ core_root: Any,
414
+ key: str,
415
+ required_present: tuple[str, ...],
416
+ required_strings: tuple[str, ...],
417
+ optional_strings: tuple[str, ...],
418
+ ) -> list[dict]:
419
+ """One of the manifest's three lists of mappings, with every field that is
420
+ non-`Option` in the Rust struct enforced and every string-typed field
421
+ normalised. A non-list value, a non-mapping entry, or a missing required field
422
+ fails the WHOLE document -- serde does too, and reading half of it would make
423
+ `tan` act on a manifest the shipped binary rejects."""
424
+ core_entries = core_root.get(key) if isinstance(core_root, dict) else None
425
+ core_entries = core_entries if isinstance(core_entries, list) else []
426
+ out: list[dict] = []
427
+ for index, entry in enumerate(_sequence(root, key)):
428
+ where = f"{key}[{index}]"
429
+ if not isinstance(entry, dict):
430
+ raise _parse_error(
431
+ f"{where}: invalid type: {_serde_type_name(entry)}, expected a map"
432
+ )
433
+ for field in required_present:
434
+ if field not in entry:
435
+ raise _parse_error(f"{where}: missing field `{field}`")
436
+ core = core_entries[index] if index < len(core_entries) else {}
437
+ _coerce_strings(entry, core, required_strings, optional_strings, where)
438
+ out.append(entry)
439
+ return out
440
+
441
+
442
+ class SystemManifest:
443
+ """A parsed, version-guarded manifest over the LITERAL-scalar tree (see
444
+ [`_literal_scalar_loader`]). Entries stay raw `dict`s -- with their
445
+ string-typed fields validated in place -- because the fields this family reads
446
+ are few, and a typed projection is exactly what makes the Rust reader lossy on
447
+ write (`serialize_system_manifest`'s own warning)."""
448
+
449
+ __slots__ = ("root", "schema_version", "sku", "slices", "helper_mcus")
450
+
451
+ def __init__(self, root: dict, core_root: Any) -> None:
452
+ self.root = root
453
+ # From the CORE tree: it is a `u32` in the Rust, and the literal tree holds
454
+ # the scalar's text (`"1"`, not `1`).
455
+ self.schema_version = core_root["schema_version"]
456
+ if "hw_info" in root:
457
+ hw_info = root["hw_info"]
458
+ if not isinstance(hw_info, dict):
459
+ raise _parse_error(
460
+ f"hw_info: invalid type: {_serde_type_name(hw_info)}, "
461
+ "expected struct HwInfo"
462
+ )
463
+ _coerce_strings(
464
+ hw_info,
465
+ core_root.get("hw_info") if isinstance(core_root, dict) else None,
466
+ _HW_INFO_REQUIRED_STRINGS,
467
+ _HW_INFO_OPTIONAL_STRINGS,
468
+ "hw_info",
469
+ )
470
+ else:
471
+ hw_info = {}
472
+ if "generated_by" in root:
473
+ root["generated_by"] = _scalar_str(
474
+ root["generated_by"], "generated_by", "SystemManifest"
475
+ )
476
+ self.sku: str = hw_info.get("sku") or ""
477
+ self.slices = _entries(
478
+ root,
479
+ core_root,
480
+ "slices",
481
+ ("core_id", "os"),
482
+ _SLICE_REQUIRED_STRINGS,
483
+ _SLICE_OPTIONAL_STRINGS,
484
+ )
485
+ self.helper_mcus = _entries(
486
+ root,
487
+ core_root,
488
+ "helper_mcus",
489
+ ("name", "chip"),
490
+ _HELPER_REQUIRED_STRINGS,
491
+ _HELPER_OPTIONAL_STRINGS,
492
+ )
493
+ # `ipc[]` and the remaining sequence fields are validated but not otherwise
494
+ # read here: a document the oracle rejects must fail this parse too, or
495
+ # `tan image` would bundle a manifest `tan flash` then refuses.
496
+ ipc = _entries(
497
+ root,
498
+ core_root,
499
+ "ipc",
500
+ ("name", "kind"),
501
+ _IPC_REQUIRED_STRINGS,
502
+ _IPC_OPTIONAL_STRINGS,
503
+ )
504
+ for entry in ipc:
505
+ if "endpoints" in entry:
506
+ raw = entry["endpoints"]
507
+ if not isinstance(raw, list):
508
+ raise _parse_error(
509
+ f"ipc.endpoints: invalid type: {_serde_type_name(raw)}, "
510
+ "expected a sequence"
511
+ )
512
+ entry["endpoints"] = [
513
+ _scalar_str(e, "endpoints", "ipc") for e in raw
514
+ ]
515
+ for key in _SEQUENCE_KEYS:
516
+ _sequence(root, key)
517
+
518
+
519
+ def parse_system_manifest(text: str) -> SystemManifest:
520
+ """Parse + version-guard a `system-manifest.yaml` document. No IO.
521
+
522
+ Order matters and mirrors serde: the document's SHAPE is checked before its
523
+ `schema_version` value, because serde fails deserialization outright on a
524
+ malformed `slices[]` and never reaches the version comparison.
525
+
526
+ TWO parses of the same text, deliberately, because serde resolves a scalar
527
+ against its TARGET type and no single Python tree can stand in for all of them:
528
+ * `schema_version` is a `u32`, so its check needs real type resolution --
529
+ `schema_version: '1'` is a hard error and `schema_version: 1` is not, and a
530
+ literal-scalar tree cannot tell those apart;
531
+ * a `String` field takes the scalar's RAW TEXT ([`_literal_scalar_loader`]);
532
+ * an `Option<String>` field takes `None` for a null, which only the resolving
533
+ tree can identify.
534
+ Both trees come from the same document, so they have the same structure and the
535
+ shape checks are identical whichever one runs them.
536
+ """
537
+ core = load_yaml_document(text)
538
+ root = load_yaml_document(text, literal_scalars=True)
539
+ if not isinstance(root, dict):
540
+ raise _parse_error(
541
+ f"invalid type: {_serde_type_name(core)}, expected struct SystemManifest"
542
+ )
543
+ if "schema_version" not in root:
544
+ raise _parse_error("missing field `schema_version`")
545
+ version = core["schema_version"] if isinstance(core, dict) else None
546
+ if isinstance(version, bool) or not isinstance(version, int) or version < 0:
547
+ raise _parse_error(
548
+ f"invalid type: {_serde_type_name(version)}, expected u32"
549
+ )
550
+ manifest = SystemManifest(root, core)
551
+ # AFTER the shape check, and comparing the full value -- a `schema_version`
552
+ # of 2**32 + 1 is congruent to 1 mod 2**32, so a truncating guard would
553
+ # wrongly accept it (`parse_system_manifest_raw_rejects_schema_version_
554
+ # beyond_u32` in the oracle).
555
+ if version != SYSTEM_MANIFEST_SCHEMA_VERSION:
556
+ raise _unsupported_version_error(version)
557
+ return manifest
558
+
559
+
560
+ # --------------------------------------------------------------------------
561
+ # The write path: overlay this run's per-slice outcomes onto a freshly
562
+ # emitted system-manifest and serialize it back out. Port of the RAW half of
563
+ # `tan_core::system_manifest` (`parse_system_manifest_raw` /
564
+ # `overlay_run_results_raw` / `serialize_system_manifest_raw`) -- the half
565
+ # `crates/tan-cli/src/commands/build/execute/manifest.rs` uses for the
566
+ # post-build rewrite, never the typed `SystemManifest`/`serialize_system_
567
+ # manifest` pair (the Rust module doc explains why: the typed struct there
568
+ # only models the fields it reads, so re-serializing it silently drops any
569
+ # field it doesn't model -- an rpmsg `IpcLink` carve-out, `hw_info.eeprom`,
570
+ # anything a newer SDK adds).
571
+ #
572
+ # That specific defect does not reproduce here: [`SystemManifest`]'s own
573
+ # `slices`/`helper_mcus` entries above are already plain `dict`s carrying
574
+ # every key the document had (`_entries` normalises known string fields IN
575
+ # PLACE and never drops the rest) -- so a plain re-dump of `SystemManifest.root`
576
+ # would not lose an additive field either. This module still keeps a distinct
577
+ # `parse_system_manifest_raw` entry point rather than reusing `parse_system_
578
+ # manifest`, for a different reason: the typed reader does TWO parses of the
579
+ # same text (a literal-scalar tree for `String` fields, a core-schema tree for
580
+ # everything else -- see `parse_system_manifest`'s own docstring) specifically
581
+ # to interpret an ARBITRARY, possibly hand-authored document the way serde_yaml
582
+ # would. The write path's input is never that: it is the SDK's own YAML,
583
+ # freshly emitted and immediately rewritten in the same step, so the single
584
+ # core-schema parse `load_yaml_document` already performs is sufficient and
585
+ # this avoids reasoning about which of the two trees a write should mutate.
586
+ # --------------------------------------------------------------------------
587
+
588
+
589
+ @dataclass(frozen=True)
590
+ class SliceRunResult:
591
+ """One slice's post-build outcome, as the executor reports it -- the
592
+ write-side counterpart of a manifest `slices[]` entry. Port of
593
+ `tan_core::system_manifest::SliceRunResult` (a 5-tuple there; a dataclass
594
+ here for readability -- same fields, same order, same "`None` leaves the
595
+ plan-time value untouched" meaning for every optional one). Pass an
596
+ EXPLICIT empty string (never `None`) to actively CLEAR a stale
597
+ `output_artefact`/`build_dir` for a slice this run knows never dispatched
598
+ (mirrors the Rust oracle's `Some(String::new())` convention for a demoted
599
+ slice)."""
600
+
601
+ core_id: str
602
+ status: str
603
+ output_artefact: str | None = None
604
+ build_dir: str | None = None
605
+ reason: str | None = None
606
+
607
+
608
+ def parse_system_manifest_raw(text: str) -> dict:
609
+ """Parse a `system-manifest.yaml` document into a raw mapping (str-in /
610
+ dict-out) -- the entry point the write path uses instead of
611
+ [`parse_system_manifest`]; see the section docstring above for why a
612
+ single core-schema parse is enough here. Still version-guarded
613
+ identically to the typed reader -- but with a DIFFERENT, collapsed error
614
+ message on a missing/malformed `schema_version`: the oracle's raw parser
615
+ (`tan_core::system_manifest::parse_system_manifest_raw`,
616
+ system_manifest.rs:294-310) extracts it with a single
617
+ `.get("schema_version").and_then(as_u64)` and reports every failure --
618
+ a missing key, a non-mapping root, a non-numeric, negative, or
619
+ out-of-`u64`-range value -- as the one string "missing or non-numeric
620
+ schema_version", not the typed reader's two-branch "missing field" /
621
+ "invalid type" pair. This string reaches a build's `issues[].message`
622
+ verbatim (a failed post-build manifest write folds it in), so the exact
623
+ wording is part of the wire contract, not cosmetic."""
624
+ root = load_yaml_document(text)
625
+ version = root.get("schema_version") if isinstance(root, dict) else None
626
+ if (
627
+ isinstance(version, bool)
628
+ or not isinstance(version, int)
629
+ or not (0 <= version <= _SERDE_INT_MAX)
630
+ ):
631
+ raise _parse_error("missing or non-numeric schema_version")
632
+ if version != SYSTEM_MANIFEST_SCHEMA_VERSION:
633
+ raise _unsupported_version_error(version)
634
+ return root
635
+
636
+
637
+ def overlay_run_results_raw(raw: dict, results: Sequence[SliceRunResult]) -> None:
638
+ """Overlay this run's per-slice outcomes onto the RAW manifest mapping (as
639
+ returned by [`parse_system_manifest_raw`]) so the written `system-
640
+ manifest.yaml` reflects what actually built. Slices are matched by
641
+ `core_id`; a slice with no matching result (e.g. an `off` core) is left
642
+ untouched. `output_artefact`/`build_dir`/`reason` are each written only
643
+ when the result carries one -- a `None` never wipes a plan-time value.
644
+ Port of `tan_core::system_manifest::overlay_run_results_raw`. Pure: no IO,
645
+ mutates `raw` in place."""
646
+ slices = raw.get("slices")
647
+ if not isinstance(slices, list):
648
+ return
649
+ # FIRST match wins, not last -- `results.iter().find(...)` in the oracle
650
+ # (system_manifest.rs:332-333). Unreachable with today's callers (one
651
+ # result per slice), but `setdefault` keeps a silent semantic flip out of
652
+ # a ported pure function rather than relying on "no caller ever hits it".
653
+ by_core: dict[str, SliceRunResult] = {}
654
+ for r in results:
655
+ by_core.setdefault(r.core_id, r)
656
+ for entry in slices:
657
+ if not isinstance(entry, dict):
658
+ continue
659
+ result = by_core.get(entry.get("core_id"))
660
+ if result is None:
661
+ continue
662
+ entry["status"] = result.status
663
+ if result.output_artefact is not None:
664
+ entry["output_artefact"] = result.output_artefact
665
+ if result.build_dir is not None:
666
+ entry["build_dir"] = result.build_dir
667
+ if result.reason is not None:
668
+ entry["reason"] = result.reason
669
+
670
+
671
+ def serialize_system_manifest_raw(raw: dict) -> str:
672
+ """Serialize the RAW manifest mapping (as returned by
673
+ [`parse_system_manifest_raw`], possibly overlaid by
674
+ [`overlay_run_results_raw`]) back to YAML. Port of `tan_core::
675
+ system_manifest::serialize_system_manifest_raw`."""
676
+ yaml = _import_yaml()
677
+ return yaml.safe_dump(raw, sort_keys=False, default_flow_style=False, allow_unicode=True)
678
+
679
+
680
+ #: `serde_yaml::Value`'s integer range: `i64::MIN ..= u64::MAX`. A YAML integer
681
+ #: outside it cannot be represented, so the WHOLE `Value` parse fails and
682
+ #: `raw_passthrough` yields its defaults -- silently dropping `hw_info` and
683
+ #: `boot_order`. Reproduced rather than "improved": Python's unbounded int would
684
+ #: otherwise put a number in `bundle-manifest.json` that no `JSON.parse` consumer
685
+ #: can hold without losing digits, which is a worse answer than the oracle's.
686
+ _SERDE_INT_MIN = -(2**63)
687
+ _SERDE_INT_MAX = 2**64 - 1
688
+
689
+
690
+ def _representable_by_serde_yaml(node: Any) -> bool:
691
+ """Whether serde_yaml could hold this whole tree in a `Value` -- an integer
692
+ bound only.
693
+
694
+ A non-finite FLOAT is deliberately representable here and must stay that
695
+ way (tan-cli#387). `.inf` / `-.inf` / `.nan` are perfectly good
696
+ `serde_yaml::Value::Number`s; returning False for them would make
697
+ `raw_passthrough` yield its empty defaults and drop the WHOLE `hw_info` and
698
+ `boot_order` on the floor, where the oracle emits every key with the
699
+ non-finite ones as `null`. The JSON side of that problem is fixed where
700
+ JSON is written (`tan.envelope.json_safe_floats`), not by refusing the
701
+ document.
702
+ """
703
+ if isinstance(node, bool):
704
+ return True
705
+ if isinstance(node, int):
706
+ return _SERDE_INT_MIN <= node <= _SERDE_INT_MAX
707
+ if isinstance(node, dict):
708
+ return all(
709
+ _representable_by_serde_yaml(k) and _representable_by_serde_yaml(v)
710
+ for k, v in node.items()
711
+ )
712
+ if isinstance(node, (list, tuple)):
713
+ return all(_representable_by_serde_yaml(v) for v in node)
714
+ return True
715
+
716
+
717
+ def raw_passthrough(text: str) -> tuple[dict, list]:
718
+ """The manifest's `hw_info` (default `{}`) + `boot_order` (default `[]`),
719
+ straight off the YAML -- bypassing any typed projection so additive schema
720
+ keys (`hw_info.eeprom`, an rpmsg carve-out, anything a newer SDK adds) pass
721
+ through unchanged into the bundle manifest.
722
+
723
+ Never fails: unparseable or non-mapping input yields the empty defaults,
724
+ matching `image_bundle::raw_passthrough`. That is what makes it safe to call
725
+ on the error path with an empty string.
726
+ """
727
+ try:
728
+ root = load_yaml_document(text)
729
+ except SystemManifestError:
730
+ root = None
731
+ if not isinstance(root, dict) or not _representable_by_serde_yaml(root):
732
+ return {}, []
733
+ hw_info = root.get("hw_info")
734
+ boot_order = root.get("boot_order")
735
+ return (
736
+ hw_info if isinstance(hw_info, dict) else {},
737
+ list(boot_order) if isinstance(boot_order, list) else [],
738
+ )
739
+
740
+
741
+ # --------------------------------------------------------------------------
742
+ # The two path rules `tan image` and `tan size` share.
743
+ # --------------------------------------------------------------------------
744
+ #
745
+ # Paths here are STRINGS joined with `os.path.join`, not `pathlib.Path`.
746
+ # `Path("C:/a/b") / "c"` re-renders the whole path in the platform separator,
747
+ # and Rust's `PathBuf::join` does not: it appends and leaves what was already
748
+ # there alone. Both values end up in `data.slices[].notes[]` and in issue
749
+ # messages, so the difference is visible in the machine contract -- a
750
+ # `/`-authored workspace root keeps its slashes with a `\` tail, exactly as the
751
+ # shipped binary reports it.
752
+
753
+
754
+ def anchor_under(path: str, build_root: str) -> str:
755
+ """A manifest path string as an absolute path: absolute as-is, relative
756
+ under `build_root` (where the manifest itself lives). Verbatim
757
+ `size.rs::anchor_under` / `image.rs`'s inline twin."""
758
+ return path if os.path.isabs(path) else os.path.join(build_root, path)
759
+
760
+
761
+ def slice_build_dir(slice_: dict, build_root: str) -> str | None:
762
+ """A slice's recorded build directory, or `None` when the manifest declares
763
+ none (a plan-time manifest, before any build ran).
764
+
765
+ `tan image` treats `None` as "skip this slice"; `tan size` falls back to the
766
+ canonical name via [`slice_build_dir_or_default`]. Both anchor the same way,
767
+ which is why the rule lives here once.
768
+ """
769
+ raw = slice_.get("build_dir")
770
+ if not isinstance(raw, str) or raw == "":
771
+ return None
772
+ return anchor_under(raw, build_root)
773
+
774
+
775
+ def slice_build_dir_or_default(slice_: dict, build_root: str) -> str:
776
+ """[`slice_build_dir`], falling back to the canonical `<core_id>-<os>`."""
777
+ recorded = slice_build_dir(slice_, build_root)
778
+ if recorded is not None:
779
+ return recorded
780
+ return os.path.join(build_root, f"{slice_.get('core_id')}-{slice_.get('os')}")
781
+
782
+
783
+ def _nested_variants(base: str) -> list[str]:
784
+ """`base`, then `base/build` -- the I-18 pair. `west build` is emitted with
785
+ NO `-d`, so its tree lands at `<slice-cwd>/build/`.
786
+
787
+ Before tan-cli#560 (alp-sdk d00dbdc1), the plan's `artifacts` block still
788
+ named the un-nested `<slice-cwd>/...`, so this reconciled the off-by-one
789
+ on every read. As of that pin the planner's own in-process output already
790
+ carries the nested path; both are still probed, un-nested first, because
791
+ an older cached plan, the alp-sdk subprocess fallback pinned to a stale
792
+ SDK, or a hand-authored manifest can still carry the un-nested spelling.
793
+
794
+ The un-nested path is tried FIRST, always: for every input where the oracle
795
+ finds something, this finds the identical thing, and the nested probe only
796
+ ever converts an oracle `not-built` into a real measurement. See
797
+ `resolve_zephyr_artefact` (`crates/tan-cli/src/commands/build/execute/
798
+ manifest.rs`), which is where the shipped binary resolves this -- at BUILD
799
+ time, by recording the nested paths into the manifest it rewrites. This
800
+ port's `tan build` does not yet write that manifest, so the reconciliation
801
+ has to happen on the read side or every slice of a real nested build reports
802
+ `not-built`.
803
+ """
804
+ return [base, os.path.join(base, WEST_NESTED_DIR)]
805
+
806
+
807
+ def slice_elf_candidates(slice_: dict, build_root: str) -> list[str]:
808
+ """Where this slice's `zephyr.elf` may be, best first.
809
+
810
+ A recorded `output_artefact` is authoritative and used ALONE -- `tan build`
811
+ only ever writes it already-resolved, so second-guessing it would ignore a
812
+ deliberate `-d`/`--build-dir` override.
813
+ """
814
+ artefact = slice_.get("output_artefact")
815
+ if isinstance(artefact, str) and artefact != "":
816
+ return [anchor_under(artefact, build_root)]
817
+ build_dir = slice_build_dir_or_default(slice_, build_root)
818
+ return [
819
+ os.path.join(base, "zephyr", "zephyr.elf")
820
+ for base in _nested_variants(build_dir)
821
+ ]
822
+
823
+
824
+ def slice_footprint_dirs(slice_: dict, build_root: str) -> list[str]:
825
+ """Where this slice's `rom.json` + `ram.json` may be, best first (same I-18
826
+ pair as [`slice_elf_candidates`])."""
827
+ return _nested_variants(slice_build_dir_or_default(slice_, build_root))