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,753 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """`tan image` -- assemble a flashable-image bundle from
3
+ `build/system-manifest.yaml`.
4
+
5
+ Port of `crates/tan-cli/src/commands/image.rs`: the IO half. tar+gzip each built
6
+ slice's `build_dir` into `image-bundle/slices/<core>-<os>.tar.gz`, copy each
7
+ helper-MCU firmware into `image-bundle/helper-mcus/`, and write
8
+ `image-bundle/bundle-manifest.json`. Every non-IO decision -- bundle shape, key
9
+ order, forward-slash artefact paths, the ok-slice gate, the path guard -- is pure
10
+ in `tan.core.image_bundle`.
11
+
12
+ **Nothing here shells the SDK, and nothing here needs one.** The main input is a
13
+ file this project's own build wrote (I-32 / anti-pattern #22); the one exception
14
+ is a helper's `firmware_path` (below), which reads a real file straight out of
15
+ an already-resolved SDK checkout when the build tree does not have it. No `sdk`
16
+ key is emitted unless the shared project resolver happened to resolve a
17
+ checkout, which is exactly what the oracle reports.
18
+
19
+ Deliberate divergences from the retired `west alp-image`, carried over from the
20
+ Rust and re-verified against the binary:
21
+ - the helper entry carries `chip`, not the perpetually-`null` `role`;
22
+ - artefact paths are forward-slash, always (the Python `\\` on Windows was a
23
+ latent cross-platform bug in the MACHINE contract);
24
+ - an IO failure is WriteFailure(3), not a traceback at rc 1;
25
+ - a helper's DECLARED, concrete `firmware_path` that does not resolve to a file
26
+ is a HARD error, not a silent skip: a consumer keying on `ok`/exit code must
27
+ not see a "complete" bundle missing a promised artefact. The `TBD` pending
28
+ sentinel and an absent `firmware_path` stay non-fatal skips.
29
+
30
+ A relative `firmware_path` resolves against **build_root, then sdk_root**
31
+ (`tan.core.image_bundle.helper_firmware_candidates`) -- alp-sdk#330: the SDK's
32
+ `som-preset-v2.schema.json` defines it repository-relative, i.e. relative to the
33
+ SDK checkout, not to this project's `build/`, so build-root-only resolution
34
+ (the pre-#330 behaviour) rejected every helper an SDK actually ships. build_root
35
+ still goes first, matching the precedence `flash_plan.resolve_artefact_path`
36
+ already established for the same two-roots ambiguity (#301, #322): a helper
37
+ artefact THIS build produced under `build/` wins over a same-named file the SDK
38
+ happens to ship. When neither root has the file, the `image.helper-missing`
39
+ error names every root tried and the absolute path each one produced, not just
40
+ the raw manifest string -- the old message left a reporter to go find the file
41
+ by hand to prove it existed.
42
+
43
+ The one divergence from the ORACLE is the archive bytes: Python's `tarfile` +
44
+ `gzip` cannot produce the Rust `tar`+`flate2` stream byte-for-byte (member order,
45
+ header fields, gzip metadata), so `slices[].sha256`/`size` differ. The contract
46
+ those fields have to satisfy is self-consistency -- each hash matches the bytes
47
+ this run actually wrote at that artefact path -- and
48
+ `tests/commands/test_image_command.py` asserts exactly that. Helper firmwares are
49
+ plain copies and DO hash identically to the oracle.
50
+ """
51
+
52
+ from __future__ import annotations
53
+
54
+ import hashlib
55
+ import json
56
+ import os
57
+ import shutil
58
+ import tarfile
59
+ from dataclasses import dataclass
60
+ from typing import Any
61
+
62
+ import typer
63
+
64
+ from tan.core.dir_removal import is_link
65
+ from tan.core.sdk_discovery import with_sdk_search
66
+ from tan.core.shapes import is_dir as _is_dir, is_file as _is_file
67
+ from tan.core.system_manifest import effective_build_root
68
+ from tan.commands.build_output import (
69
+ ManifestInvalid,
70
+ ManifestUnavailable,
71
+ ProjectContext,
72
+ load_manifest,
73
+ resolve_app_base,
74
+ resolve_build_root,
75
+ resolve_project_context,
76
+ )
77
+ from tan.core.global_flags import accept_global_flags
78
+ from tan.core.image_bundle import (
79
+ BUNDLE_DIR,
80
+ BUNDLE_MANIFEST,
81
+ HELPERS_DIR,
82
+ PENDING_SENTINEL,
83
+ SLICES_DIR,
84
+ assemble_bundle_manifest,
85
+ helper_artefact_rel,
86
+ helper_entry,
87
+ helper_firmware_candidates,
88
+ slice_archive_name,
89
+ slice_artefact_rel,
90
+ slice_entry,
91
+ slice_should_bundle,
92
+ )
93
+ from tan.core.sdk_discovery import sdk_resolution_issues
94
+ from tan.core.system_manifest import (
95
+ SystemManifest,
96
+ raw_passthrough,
97
+ slice_build_dir,
98
+ )
99
+ from tan.envelope import Envelope, Issue, Project, SdkDisclosure, SdkInfo, emit, json_safe_floats
100
+ from tan.exit_codes import ExitCode
101
+ from tan.output_format import FORMAT_HELP, OutputFormat, resolve_format
102
+
103
+ #: Streaming read size for the SHA-256, matching the oracle's 65536-byte buffer.
104
+ _HASH_CHUNK = 65536
105
+
106
+
107
+ class BundleWriteError(Exception):
108
+ """A bundle-assembly IO failure -- `image.bundle-write-failed` at exit 3."""
109
+
110
+ def __init__(self, message: str) -> None:
111
+ super().__init__(message)
112
+ self.message = message
113
+
114
+
115
+ @dataclass
116
+ class _Notice:
117
+ """A slice or helper item left OUT of the bundle. Carries its own issue code
118
+ so `--format json` reports it as an `Issue`, not only as a text line: before
119
+ that, an incomplete bundle was indistinguishable from a complete one in the
120
+ mode the extension always uses.
121
+
122
+ `severity` is `warning` for a legitimate, already-accounted-for gap (no
123
+ build_dir yet, an unsafe core_id/os, a `TBD` helper). The one `error` case is
124
+ a helper's declared, concrete `firmware_path` that is not a file -- and that
125
+ flips the exit/`ok`, even though assembly itself completed.
126
+ """
127
+
128
+ code: str
129
+ severity: str
130
+ message: str
131
+
132
+
133
+ @dataclass
134
+ class _Outcome:
135
+ exit_code: ExitCode
136
+ data: dict[str, Any]
137
+ project: Project
138
+ issues: list[Issue]
139
+ text: list[str]
140
+ sdk: SdkInfo | None = None
141
+
142
+
143
+ def _sha256_and_size(path: str) -> tuple[str, int]:
144
+ """Streaming lowercase-hex SHA-256 + byte length. Raises
145
+ [`BundleWriteError`] rather than an OSError, so no read failure can escape
146
+ the error contract."""
147
+ digest = hashlib.sha256()
148
+ try:
149
+ with open(path, "rb") as handle:
150
+ while True:
151
+ chunk = handle.read(_HASH_CHUNK)
152
+ if not chunk:
153
+ break
154
+ digest.update(chunk)
155
+ size = os.stat(path).st_size
156
+ except OSError as err:
157
+ raise BundleWriteError(f"hash {path}: {err}") from err
158
+ return digest.hexdigest(), size
159
+
160
+
161
+ def _basename(path: str) -> str:
162
+ """`Path::file_name()`: the last component, with any trailing separator
163
+ ignored (`os.path.basename` returns `""` there, Rust's `file_name` does not)
164
+ and `/` honoured as a separator on Windows too."""
165
+ return os.path.basename(path.replace("\\", "/").rstrip("/"))
166
+
167
+
168
+ def _tar_gzip_dir(src_dir: str, dest: str) -> None:
169
+ """Recursively tar+gzip `src_dir` into `dest`, tar root arcname =
170
+ basename(src_dir). Any existing archive is unlinked first, matching the
171
+ oracle (and so a half-written archive from a previous run can never be
172
+ appended to)."""
173
+ try:
174
+ if os.path.lexists(dest):
175
+ os.remove(dest)
176
+ except OSError as err:
177
+ raise BundleWriteError(f"unlink {dest}: {err}") from err
178
+ arcname = _basename(src_dir) or "build"
179
+ try:
180
+ with tarfile.open(dest, "w:gz") as archive:
181
+ archive.add(src_dir, arcname=arcname)
182
+ except (OSError, tarfile.TarError) as err:
183
+ raise BundleWriteError(f"tar {src_dir}: {err}") from err
184
+
185
+
186
+ def _bundle_slice(
187
+ slice_: dict, build_root: str, slices_dir: str
188
+ ) -> dict[str, Any] | _Notice:
189
+ """Tar+gzip one ok slice, or a notice saying why it was left out."""
190
+ core_id = str(slice_.get("core_id"))
191
+ os_name = str(slice_.get("os"))
192
+ build_dir = slice_build_dir(slice_, build_root)
193
+ if build_dir is None or not _is_dir(build_dir):
194
+ return _Notice(
195
+ "image.slice-skipped",
196
+ "warning",
197
+ f"image: skipping {core_id} (build_dir missing)",
198
+ )
199
+ # The shared seam that rejects a `core_id`/`os` shaped like `../../x` or
200
+ # `C:/x`: without it, joining onto `slices_dir` escaped the bundle dir and the
201
+ # unlink above could remove an arbitrary `*.tar.gz` outside the build tree.
202
+ archive_name = slice_archive_name(core_id, os_name)
203
+ if archive_name is None:
204
+ return _Notice(
205
+ "image.slice-unsafe-name",
206
+ "warning",
207
+ f"image: skipping {core_id} (core_id/os is not a safe archive name)",
208
+ )
209
+ archive = os.path.join(slices_dir, archive_name)
210
+ _tar_gzip_dir(build_dir, archive)
211
+ sha256, size = _sha256_and_size(archive)
212
+ artefact = slice_artefact_rel(core_id, os_name)
213
+ assert artefact is not None # already validated by slice_archive_name above
214
+ return slice_entry(core_id, os_name, artefact, sha256, size)
215
+
216
+
217
+ def _unresolved_sdk_clause(sdk_root_arg: str | None, workspace_root: str | None = None) -> str:
218
+ """The trailing "why was there no sdk_root" clause of an
219
+ `image.helper-missing` message.
220
+
221
+ tan-cli#497 defect 6: the message hardcoded "no --sdk-root and no
222
+ discoverable checkout" whenever nothing resolved -- including when
223
+ `--sdk-root` WAS supplied and failed the loader-marker check, because
224
+ `resolve_project_context` returns `None` for a supplied-but-invalid flag
225
+ exactly as it does for an absent one. So `tan image --sdk-root
226
+ /definitely/not/a/checkout` told the reporter to pass the flag they had
227
+ just typed, on a hard-error path (exit 1) that refuses to produce the
228
+ bundle -- sending them to look for a missing flag instead of at the typo
229
+ in the path they gave.
230
+
231
+ Entirely inside the `(tried ...)` clause that `tests/parity/
232
+ test_image_size_oracle.py` stripped from BOTH modes as a declared
233
+ alp-sdk#330 divergence (the frozen oracle has no `sdk_root` fallback and
234
+ so no such clause at all). That module went with the oracle axis in
235
+ tan-cli#269, so no parity CASE diffs it any more -- but BOTH branches of
236
+ the string below are asserted verbatim by
237
+ `tests/commands/test_image_command.py`'s
238
+ `test_a_rejected_sdk_root_flag_is_named_not_reported_as_absent` and
239
+ `test_an_absent_sdk_root_flag_still_says_so`, so a reword lands there too.
240
+ """
241
+ if sdk_root_arg is None:
242
+ clause = "sdk root not resolved (no --sdk-root and no discoverable checkout)"
243
+ # tan-cli#1463: name where the ladder looked, like every other
244
+ # SDK-root refusal.
245
+ return with_sdk_search(clause, workspace_root) if workspace_root else clause
246
+ return (
247
+ f'sdk root not resolved (--sdk-root "{sdk_root_arg}" is not an alp-sdk '
248
+ f"checkout)"
249
+ )
250
+
251
+
252
+ def _bundle_helper(
253
+ helper: dict,
254
+ build_root: str,
255
+ sdk_root: str | None,
256
+ helpers_dir: str,
257
+ used_names: set[str],
258
+ sdk_root_arg: str | None = None,
259
+ workspace_root: str | None = None,
260
+ ) -> dict[str, Any] | _Notice | None:
261
+ """Copy one helper's firmware, or a notice, or `None` for an absent one.
262
+
263
+ `sdk_root_arg` is the RAW `--sdk-root` the caller typed, carried only so
264
+ the unresolved-sdk clause can tell "you passed nothing" apart from "what
265
+ you passed is not a checkout" (tan-cli#497 defect 6); `sdk_root` remains
266
+ the RESOLVED root, and is the only one any path operation uses.
267
+
268
+ `used_names` tracks every destination basename already claimed this run:
269
+ Zephyr's default layout puts EVERY helper's firmware at the same basename
270
+ (`zephyr.bin`), flattened from different `firmware_path`s. Without it, the
271
+ second helper's copy silently overwrote the first's file and the first's
272
+ already-recorded `sha256` pointed at bytes that were no longer there.
273
+ """
274
+ raw = helper.get("firmware_path")
275
+ if not isinstance(raw, str) or raw == "":
276
+ return None
277
+ if raw.strip() == PENDING_SENTINEL:
278
+ # The documented not-yet-built placeholder: a legitimate state, so a
279
+ # warning, never the error a genuinely missing concrete path gets.
280
+ return _Notice(
281
+ "image.helper-skipped",
282
+ "warning",
283
+ f"image: helper-mcu firmware not found at {raw}; skipping",
284
+ )
285
+ # Two roots, tried in order -- see `helper_firmware_candidates` for why
286
+ # build_root goes first and sdk_root is the fallback that resolves a
287
+ # genuinely SDK-shipped, repository-relative firmware_path (alp-sdk#330).
288
+ candidates = helper_firmware_candidates(raw, build_root, sdk_root)
289
+ firmware = next((path for _, path in candidates if _is_file(path)), None)
290
+ if firmware is None:
291
+ tried = "; ".join(f"{label} {path}" for label, path in candidates)
292
+ if sdk_root is None and not os.path.isabs(raw):
293
+ tried += f"; {_unresolved_sdk_clause(sdk_root_arg, workspace_root)}"
294
+ return _Notice(
295
+ "image.helper-missing",
296
+ "error",
297
+ f"image: helper-mcu firmware not found at {raw} (tried {tried}); "
298
+ "refusing to produce an incomplete bundle",
299
+ )
300
+ basename = _basename(firmware) or "firmware.bin"
301
+ dest_name = basename
302
+ counter = 1
303
+ while dest_name in used_names:
304
+ counter += 1
305
+ dest_name = f"{counter}-{basename}"
306
+ used_names.add(dest_name)
307
+
308
+ destination = os.path.join(helpers_dir, dest_name)
309
+ try:
310
+ _copy_file(firmware, destination)
311
+ except OSError as err:
312
+ raise BundleWriteError(f"copy {firmware} -> {destination}: {err}") from err
313
+ sha256, size = _sha256_and_size(destination)
314
+ name = helper.get("name")
315
+ chip = helper.get("chip")
316
+ return helper_entry(
317
+ name if isinstance(name, str) else None,
318
+ chip if isinstance(chip, str) else None,
319
+ helper_artefact_rel(dest_name),
320
+ sha256,
321
+ size,
322
+ )
323
+
324
+
325
+ def _copy_file(src: str, dst: str) -> None:
326
+ """Byte copy, streamed. `shutil.copy` is avoided deliberately: it also copies
327
+ permission bits, and on a read-only source firmware that leaves the bundle
328
+ copy unwritable, so the NEXT `tan image` run fails to overwrite it."""
329
+ with open(src, "rb") as reader, open(dst, "wb") as writer:
330
+ while True:
331
+ chunk = reader.read(_HASH_CHUNK)
332
+ if not chunk:
333
+ break
334
+ writer.write(chunk)
335
+
336
+
337
+ def _prune_dir_contents(directory: str) -> None:
338
+ """Remove every entry directly inside `directory` (links unlinked, never
339
+ followed), leaving the directory itself."""
340
+ try:
341
+ entries = os.listdir(directory)
342
+ except OSError as err:
343
+ raise BundleWriteError(f"list {directory}: {err}") from err
344
+ for name in entries:
345
+ entry = os.path.join(directory, name)
346
+ try:
347
+ if os.path.isdir(entry) and not os.path.islink(entry):
348
+ shutil.rmtree(entry)
349
+ else:
350
+ os.remove(entry)
351
+ except OSError as err:
352
+ raise BundleWriteError(f"remove stale {entry}: {err}") from err
353
+
354
+
355
+ def _assemble_bundle(
356
+ build_root: str,
357
+ sdk_root: str | None,
358
+ manifest: SystemManifest,
359
+ yaml_text: str,
360
+ sdk_root_arg: str | None = None,
361
+ workspace_root: str | None = None,
362
+ ) -> tuple[list[_Notice], dict[str, Any], str]:
363
+ """Do the filesystem work: mkdir the bundle tree, tar each ok slice, copy each
364
+ present helper firmware, write `bundle-manifest.json`."""
365
+ bundle_dir = os.path.join(build_root, BUNDLE_DIR)
366
+ slices_dir = os.path.join(bundle_dir, SLICES_DIR)
367
+ helpers_dir = os.path.join(bundle_dir, HELPERS_DIR)
368
+ for directory in (bundle_dir, slices_dir, helpers_dir):
369
+ # `makedirs(exist_ok=True)` accepts a symlink to a directory, and the
370
+ # prune below would then empty its out-of-tree target. Unlink the link
371
+ # and recreate a real directory.
372
+ if directory != bundle_dir and is_link(directory):
373
+ try:
374
+ os.unlink(directory)
375
+ except OSError:
376
+ try:
377
+ os.rmdir(directory)
378
+ except OSError as err:
379
+ raise BundleWriteError(f"unlink {directory}: {err}") from err
380
+ try:
381
+ os.makedirs(directory, exist_ok=True)
382
+ except OSError as err:
383
+ raise BundleWriteError(f"mkdir {directory}: {err}") from err
384
+ # tan-cli#1482: this run's bundle is what the directory holds -- archives and
385
+ # helper firmware left by an EARLIER run (a slice that has since failed or
386
+ # been skipped, a helper dropped from the manifest) are pruned so a consumer
387
+ # that ships the directory never gets firmware this build did not produce.
388
+ # tan-cli#1521: the previous bundle-manifest.json goes FIRST. It names the
389
+ # archives pruned below, so a run that dies mid-assembly would otherwise
390
+ # leave a manifest pointing at deleted or re-hashed archives.
391
+ stale_manifest = os.path.join(bundle_dir, BUNDLE_MANIFEST)
392
+ try:
393
+ os.remove(stale_manifest)
394
+ except FileNotFoundError:
395
+ pass
396
+ except OSError as err:
397
+ raise BundleWriteError(f"remove stale {stale_manifest}: {err}") from err
398
+ for directory in (slices_dir, helpers_dir):
399
+ _prune_dir_contents(directory)
400
+
401
+ notices: list[_Notice] = []
402
+ slice_entries: list[dict[str, Any]] = []
403
+ for slice_ in manifest.slices:
404
+ status = slice_.get("status")
405
+ if not slice_should_bundle(status):
406
+ # tan-cli#499 defect 1: a bare `continue` here -- the ONE exclusion
407
+ # in this function that reported nothing, while `build_dir`-missing,
408
+ # unsafe-name and the `TBD` helper all emit a notice. Reachable
409
+ # from a GREEN `tan build`: a slice skipped under executionPolicy
410
+ # (no `west`/`bitbake`) is written as `status: skipped` and
411
+ # `build_cmd` keeps a PARTIAL build at ok:true/exit 0, so `tan
412
+ # image` answered `ok:true`, `exitCode:0`, `issues:[]` and wrote a
413
+ # `bundle-manifest.json` whose `boot_order` still named a core
414
+ # `slices[]` carried no artefact for -- a release/OTA consumer
415
+ # keying on `ok`/`issues[]` ships a bundle missing a core's
416
+ # firmware.
417
+ #
418
+ # A DELIBERATE oracle divergence, measured before diverging:
419
+ #
420
+ # $ target/debug/tan image --format json --build-root build # tan 0.4.1
421
+ # ...,"slices":[{"core_id":"m55_hp",...}],
422
+ # "boot_order":[{"core":"m55_hp"},{"core":"a55_0"}]},"issues":[]}
423
+ #
424
+ # i.e. the frozen oracle drops it silently too (`image.rs`'s bare
425
+ # `continue`). Warning severity, not an exit-code flip: this is
426
+ # the same "already-accounted-for gap" class `_Notice` documents,
427
+ # and a `failed` status has already exited non-zero at `tan build`.
428
+ #
429
+ # Only a DECLARED status reports -- the same narrowing `size`'s
430
+ # twin guard applies, for the same reason: a manifest that omits
431
+ # `status` says nothing about whether the slice built, and this
432
+ # module's reader is "deliberately tolerant, not a validator". It
433
+ # also confines the divergence to the shape the defect actually
434
+ # has (`tan build` always writes a `status`) instead of every
435
+ # hand-written manifest in the parity corpus.
436
+ if isinstance(status, str):
437
+ notices.append(
438
+ _Notice(
439
+ "image.slice-skipped",
440
+ "warning",
441
+ f"image: skipping {slice_.get('core_id')} (status: {status})",
442
+ )
443
+ )
444
+ continue
445
+ result = _bundle_slice(slice_, build_root, slices_dir)
446
+ if isinstance(result, _Notice):
447
+ notices.append(result)
448
+ else:
449
+ slice_entries.append(result)
450
+
451
+ helper_entries: list[dict[str, Any]] = []
452
+ used_names: set[str] = set()
453
+ for helper in manifest.helper_mcus:
454
+ result = _bundle_helper(
455
+ helper, build_root, sdk_root, helpers_dir, used_names, sdk_root_arg, workspace_root
456
+ )
457
+ if result is None:
458
+ continue
459
+ if isinstance(result, _Notice):
460
+ notices.append(result)
461
+ else:
462
+ helper_entries.append(result)
463
+
464
+ hw_info, boot_order = raw_passthrough(yaml_text)
465
+ bundle = assemble_bundle_manifest(hw_info, boot_order, slice_entries, helper_entries)
466
+
467
+ try:
468
+ # `indent=2` + serde_json's `to_string_pretty` separators, and exactly one
469
+ # trailing newline written with `newline=""` so a Windows host cannot
470
+ # silently rewrite the file to CRLF (I-27).
471
+ #
472
+ # `json_safe_floats` for the same reason the envelope applies it, and it
473
+ # matters MORE here (tan-cli#387): this is a persisted artefact that
474
+ # downstream flashing/OTA tooling parses long after the run that wrote
475
+ # it. A `.inf` in `hw_info` used to leave `Infinity` on disk inside a
476
+ # bundle tan had just reported as successfully assembled, so every
477
+ # strict consumer of that file failed with nothing in the envelope
478
+ # saying why. It cannot be shared with the envelope's own call -- this
479
+ # document is written before `data` is built, and the two go through
480
+ # different `json.dumps` calls.
481
+ document = json.dumps(json_safe_floats(bundle), indent=2) + "\n"
482
+ except (TypeError, ValueError) as err:
483
+ # `hw_info` is verbatim YAML: a mapping key JSON cannot express reaches
484
+ # here. The oracle hits the same wall in `Envelope::to_json` and reports
485
+ # it as an issue rather than aborting; this reports it as a write failure,
486
+ # which is where the failure actually is.
487
+ raise BundleWriteError(f"serialize {BUNDLE_MANIFEST}: {err}") from err
488
+ manifest_out = os.path.join(bundle_dir, BUNDLE_MANIFEST)
489
+ try:
490
+ with open(manifest_out, "w", encoding="utf-8", newline="") as handle:
491
+ handle.write(document)
492
+ except OSError as err:
493
+ raise BundleWriteError(f"write {manifest_out}: {err}") from err
494
+
495
+ return notices, bundle, bundle_dir
496
+
497
+
498
+ def _empty_bundle() -> dict[str, Any]:
499
+ """An empty bundle manifest, so the envelope's `data` shape stays stable on
500
+ every failure path."""
501
+ return assemble_bundle_manifest({}, [], [], [])
502
+
503
+
504
+ def _sdk_warning_lines(issues: list[Issue]) -> list[str]:
505
+ """The text-mode rendering of the `sdk_resolution_issues` pair
506
+ (tan-cli#497 defect 5). `{severity}: {message}` -- the shape `tan build`
507
+ and `tan run` already print a resolution warning with, so the same
508
+ workspace reads the same way whichever command a developer runs.
509
+
510
+ Nothing pins the previous silence: `test_text_mode_writes_nothing_to_
511
+ stdout_and_always_names_the_bundle` asserts only containment, and the
512
+ oracle text-parity cases redirect HOME and set no `.alp/sdk-path`, so
513
+ neither warning can fire there."""
514
+ return [f"{issue.severity}: {issue.message}" for issue in issues]
515
+
516
+
517
+ def _error_outcome(
518
+ project: Project,
519
+ context: ProjectContext,
520
+ exit_code: ExitCode,
521
+ code: str,
522
+ message: str,
523
+ ) -> _Outcome:
524
+ """tan-cli#464 review: took a bare `sdk: SdkInfo | None` and reported only
525
+ the `code`/`message` issue -- so the manifest gate (the dominant refusal
526
+ path, reached before `_run`'s own `sdk_resolution_issues` call further
527
+ down) reported neither `sdk.project-pin-unresolved` nor
528
+ `sdk.global-default-foreign-project`, even with `sdk.sourceTier` naming a
529
+ tier that should have triggered one. Takes the whole `context` so every
530
+ caller -- including a bundle-write failure, past the manifest gate -- gets
531
+ the same pair from the one shared `sdk_resolution_issues`.
532
+
533
+ tan-cli#497 defect 5: that fix reached `issues` and stopped there -- `text`
534
+ was built without the pair, so the DEFAULT mode dropped both warnings
535
+ while `--format json` reported them. Composed from the SAME list now, so
536
+ the two channels cannot disagree."""
537
+ issues = sdk_resolution_issues(
538
+ context.broken_project_pin, context.sdk_source_tier, context.foreign_global_default_for
539
+ )
540
+ text = _sdk_warning_lines(issues)
541
+ issues.append(Issue(code, "error", message))
542
+ return _Outcome(
543
+ exit_code,
544
+ _empty_bundle(),
545
+ project,
546
+ issues,
547
+ [*text, f"image: {message}"],
548
+ context.sdk,
549
+ )
550
+
551
+
552
+ def _run(
553
+ *,
554
+ app_path: str | None,
555
+ build_root_arg: str | None,
556
+ project_arg: str | None,
557
+ board_yaml_arg: str | None,
558
+ sdk_root_arg: str | None,
559
+ disclosure: SdkDisclosure,
560
+ ) -> _Outcome:
561
+ """`disclosure` is the caller's, by reference -- the resolution facts are
562
+ computed HERE and `image`'s `image.internal-failure` catch-all needs a name
563
+ to read them from once this function has already raised. See
564
+ `SdkDisclosure`."""
565
+ context: ProjectContext = resolve_project_context(
566
+ project_arg, board_yaml_arg, sdk_root_arg
567
+ )
568
+ project = context.project()
569
+ # Recorded the instant the ladder answers, ahead of every other step this
570
+ # function performs -- all of which can raise something unenumerated.
571
+ disclosure.record(
572
+ context.sdk,
573
+ sdk_resolution_issues(
574
+ context.broken_project_pin,
575
+ context.sdk_source_tier,
576
+ context.foreign_global_default_for,
577
+ ),
578
+ )
579
+ app_base = resolve_app_base(app_path, context.workspace_root)
580
+ build_root = resolve_build_root(build_root_arg, app_base)
581
+
582
+ build_root = effective_build_root(build_root)
583
+ try:
584
+ yaml_text, manifest = load_manifest(build_root)
585
+ except ManifestUnavailable as err:
586
+ # Note the asymmetry with `size`, which is faithful: `image` does NOT put
587
+ # the OS error in its message, and a non-UTF-8 manifest is "not found"
588
+ # here too (the read fails before the parser ever sees it).
589
+ return _error_outcome(
590
+ project,
591
+ context,
592
+ ExitCode.RUNTIME_FAILURE,
593
+ "image.manifest-unavailable",
594
+ f"system-manifest.yaml not found at {err.path}; run `tan build` first.",
595
+ )
596
+ except ManifestInvalid as err:
597
+ return _error_outcome(
598
+ project,
599
+ context,
600
+ ExitCode.RUNTIME_FAILURE,
601
+ "image.manifest-invalid",
602
+ f"{err.path}: {err.detail}",
603
+ )
604
+
605
+ sdk_root = context.sdk.root if context.sdk is not None else None
606
+ try:
607
+ notices, bundle, bundle_dir = _assemble_bundle(
608
+ build_root, sdk_root, manifest, yaml_text, sdk_root_arg, str(context.workspace_root)
609
+ )
610
+ except BundleWriteError as err:
611
+ return _error_outcome(
612
+ project,
613
+ context,
614
+ ExitCode.WRITE_FAILURE,
615
+ "image.bundle-write-failed",
616
+ err.message,
617
+ )
618
+
619
+ # Not always a green exit: an `error`-severity notice (a helper's concrete,
620
+ # declared firmware_path that is not a file) flips exit/`ok` even though
621
+ # assembly completed -- mkdir/tar/write all succeeded, so the manifest and
622
+ # every OTHER artefact are still on disk and inspectable.
623
+ exit_code = (
624
+ ExitCode.RUNTIME_FAILURE
625
+ if any(n.severity == "error" for n in notices)
626
+ else ExitCode.SUCCESS
627
+ )
628
+ issues = [Issue(n.code, n.severity, n.message) for n in notices]
629
+ # The same pair `_error_outcome` above computes for a manifest/bundle-write
630
+ # refusal, from the same shared `sdk_resolution_issues` -- read back off
631
+ # the disclosure rather than computed a second time, so the happy path, the
632
+ # refusals and the catch-all cannot disagree.
633
+ sdk_issues = list(disclosure.issues)
634
+ issues.extend(sdk_issues)
635
+ return _Outcome(
636
+ exit_code,
637
+ bundle,
638
+ project,
639
+ issues,
640
+ # tan-cli#497 defect 5, happy-path half: a bundle whose helper
641
+ # firmwares came out of a FALLBACK checkout used to be reported as a
642
+ # plain `image: bundle ready at ...` with no mention that the project
643
+ # pin had been ignored.
644
+ _sdk_warning_lines(sdk_issues)
645
+ + [n.message for n in notices]
646
+ + [f"image: bundle ready at {bundle_dir}"],
647
+ context.sdk,
648
+ )
649
+
650
+
651
+ def image(
652
+ ctx: typer.Context,
653
+ app_path: str = typer.Argument(
654
+ None,
655
+ metavar="APP_PATH",
656
+ help="Application source directory (default: the resolved --project "
657
+ "workspace). build_root defaults to <APP_PATH>/build.",
658
+ ),
659
+ build_root: str = typer.Option(
660
+ None,
661
+ "--build-root",
662
+ metavar="PATH",
663
+ help="Override the build root holding system-manifest.yaml "
664
+ "(default: <APP_PATH>/build).",
665
+ ),
666
+ project: str = typer.Option(
667
+ None, "--project", metavar="PATH", help="Project root (defaults to '.')."
668
+ ),
669
+ board_yaml: str = typer.Option(
670
+ None, "--board-yaml", metavar="PATH", help="Explicit board.yaml path."
671
+ ),
672
+ sdk_root: str = typer.Option(
673
+ None, "--sdk-root", metavar="PATH", help="alp-sdk checkout root."
674
+ ),
675
+ output_format: OutputFormat = typer.Option(None, "--format", help=FORMAT_HELP),
676
+ ) -> None:
677
+ """Assemble a flashable-image bundle from build/system-manifest.yaml."""
678
+ resolved_format = resolve_format(output_format, ctx.obj, choices=OutputFormat)
679
+ json_mode = resolved_format == "json"
680
+
681
+ # tan-cli#497 defect 5, the site the first pass missed. `_error_outcome` and
682
+ # the happy path both report the SDK-resolution pair; this handler -- which
683
+ # runs strictly after `resolve_project_context` has already answered --
684
+ # reported only the crash, so a `.alp/sdk-path` the run ignored stayed
685
+ # silent on exactly the path where a reader most needs to know which
686
+ # checkout was in play. Recorded rather than recomputed here: the resolver
687
+ # is itself one of the things that can raise, and this handler must not.
688
+ disclosure = SdkDisclosure()
689
+ try:
690
+ outcome = _run(
691
+ app_path=app_path,
692
+ build_root_arg=build_root,
693
+ project_arg=project,
694
+ board_yaml_arg=board_yaml,
695
+ sdk_root_arg=sdk_root,
696
+ disclosure=disclosure,
697
+ )
698
+ except Exception as err: # noqa: BLE001
699
+ # The port's most-repeated defect class: a traceback puts nothing
700
+ # parseable on stdout and the extension renders an empty panel with no
701
+ # error. Anything reaching here is a tan bug, reported as one. Nothing in
702
+ # this handler can itself throw -- `_empty_bundle()` and
703
+ # `Project(None, None)` are both total, `_sdk_warning_lines` only
704
+ # formats, and no path helper is called.
705
+ outcome = _Outcome(
706
+ ExitCode.INTERNAL_FAILURE,
707
+ _empty_bundle(),
708
+ Project(root=None, board_yaml=None),
709
+ [
710
+ *disclosure.issues,
711
+ Issue(
712
+ "image.internal-failure",
713
+ "error",
714
+ f"image failed unexpectedly: {type(err).__name__}: {err}",
715
+ ),
716
+ ],
717
+ [*_sdk_warning_lines(disclosure.issues), "image: internal failure"],
718
+ disclosure.sdk,
719
+ )
720
+
721
+ # Built ONCE, for both formats: `Envelope.__init__` appends the tan-cli#407
722
+ # `sdk.discovery-divergent` warning at the shared seam (`_with_sdk_
723
+ # divergence`), and `outcome.text` was assembled strictly before any
724
+ # `Envelope` existed -- so a seam-appended issue reached `--format json`
725
+ # and was silent on the default text channel (tan-cli#799). Diffed
726
+ # against `outcome.issues` (by value: `Issue` is a frozen dataclass) so
727
+ # only what the seam ADDED is rendered, never a duplicate of a warning
728
+ # `outcome.text` already carries via `_sdk_warning_lines` above.
729
+ envelope = Envelope(
730
+ "image",
731
+ outcome.project,
732
+ outcome.data,
733
+ outcome.issues,
734
+ outcome.exit_code,
735
+ sdk=outcome.sdk,
736
+ )
737
+ if json_mode:
738
+ emit(envelope)
739
+ else:
740
+ seam_extra = [issue for issue in envelope.issues if issue not in outcome.issues]
741
+ stream = typer.get_text_stream("stderr")
742
+ for issue in seam_extra:
743
+ stream.write(f"{issue.severity}: {issue.message}\n")
744
+ for line in outcome.text:
745
+ stream.write(f"{line}\n")
746
+ raise typer.Exit(int(outcome.exit_code))
747
+
748
+
749
+ # tan-cli#261: adds the seven oracle `GlobalArgs` flags this command was
750
+ # still missing (`--all`/`--ci`/`--no-color`/`--non-interactive`/`--quiet`/
751
+ # `--target`/`--verbose`) on top of `--board-yaml`, already declared and read
752
+ # above; see `tan.core.global_flags`.
753
+ image = accept_global_flags(image)