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,178 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """A durable atomic-write helper (`atomic_write_text`, plus the byte-exact
3
+ `atomic_write_bytes`) for a file more than one process or SDK checkout can
4
+ share -- fsync'd before the rename, not just atomic-in-naming.
5
+
6
+ tan-cli#516: `bootstrap_cmd.reconcile_west_manifest_path` wrote its temp
7
+ sibling with `Path.write_text` and a bare `os.replace`, with no `os.fsync` on
8
+ the temp before the rename and no directory `fsync` after it. `os.replace`'s
9
+ atomicity guarantee covers the RENAME only -- it says nothing about whether
10
+ the renamed-to content has actually reached stable storage. A crash between
11
+ the rename landing and the temp's data blocks flushing can leave `.west/
12
+ config` existing under its real name with truncated or missing content, on
13
+ any filesystem that does not happen to order the two together: ext4's
14
+ `auto_da_alloc` heuristic covers the common Linux case; XFS, btrfs, APFS,
15
+ NTFS via `MoveFileExW`, and network filesystems generally do not.
16
+
17
+ This is the SAME durability + symlink-safety + mode-preservation shape
18
+ `debug_config_cmd._atomic_write_launch_json` carried (tan-cli#489, review
19
+ findings 4+5) for the identical reason -- that function's own opening comment
20
+ already called its shape "matching `bootstrap_cmd.reconcile_west_manifest_
21
+ path`'s own pattern", so the two were always meant to agree; #516 is that
22
+ agreement breaking (the fsync fix landed on one call site, not the other, and
23
+ outlived it). Extracted here rather than re-copied inline a second time, so a
24
+ future edit to the durability sequence itself cannot re-diverge between call
25
+ sites the way it just did -- tan-cli#510 was exactly this failure mode: two
26
+ independent copies of a resolver that drifted apart. `debug_config_cmd.py`'s
27
+ own copy is gone as of the #516 review round -- `_write` now calls this
28
+ function directly, rather than keeping a second, hand-synchronised
29
+ implementation beside it -- so the docstring's promise and the code agree.
30
+
31
+ **Mode preservation mirrors `_atomic_write_launch_json` exactly**, because
32
+ this helper now serves BOTH call sites and one of them (`.vscode/
33
+ launch.json`) genuinely needs it: a `664`-mode file a shared checkout
34
+ convention set deliberately must not come back at `mkstemp`'s hardcoded
35
+ `0600` merely because it was rewritten. `.west/config` -- this helper's other
36
+ caller -- has no such convention to preserve, but reading a mode that already
37
+ exists and reapplying it after the swap is a no-op cost for that caller, not
38
+ a hazard, so one shape serves both rather than growing a caller-specific
39
+ branch.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import os
45
+ import stat
46
+ import tempfile
47
+
48
+
49
+ def atomic_write_text(path: str, content: str, *, encoding: str = "utf-8") -> None:
50
+ """Write `content` to `path` atomically and durably. Raises on any
51
+ failure; the caller decides how to report it.
52
+
53
+ * **Symlink-safe.** `os.path.realpath` resolves the real target FIRST,
54
+ both for where the temp sibling is created (the same directory, and so
55
+ the same filesystem -- `os.replace` requires that for its atomicity
56
+ guarantee and fails outright with `EXDEV` without it) and for what
57
+ `os.replace` itself targets: writing the temp beside the UNresolved
58
+ `path` and replacing that would put a regular file where a symlink was,
59
+ destroying the link, while the real file silently stops being updated.
60
+ * **Durable, not just atomic-in-naming.** `os.fsync` on the temp file's
61
+ own descriptor, before the rename, gets the CONTENT onto stable
62
+ storage; a POSIX directory `fsync` after the rename covers the RENAME
63
+ ENTRY surviving a crash too (Windows has no directory handle to fsync,
64
+ and `ReplaceFile`/`MoveFileExW` already journal the rename itself).
65
+ * **Mode-preserving.** The existing file's permission bits (`stat.S_IMODE`,
66
+ read BEFORE the write) are re-applied to the real path AFTER a
67
+ successful `os.replace` -- never to the temp file itself, since
68
+ `os.replace` swaps the directory entry, not the inode's own mode bits,
69
+ and `mkstemp`'s hardcoded `0600` would otherwise silently narrow
70
+ whatever the file used to be. A first-ever write (no existing file to
71
+ read a mode from) gets `0666 & ~umask` instead of keeping `mkstemp`'s
72
+ `0600` forever, so it starts out at the ordinary default a plain
73
+ `open(path, "w")` would have produced.
74
+ * **Cleans up its own temp on failure** -- the caller never needs a
75
+ matching `unlink` in its own `except` block. This covers more than
76
+ `OSError`: an unencodable `content` under `encoding` raises
77
+ `UnicodeEncodeError` (a `ValueError`) from `handle.write`, and an
78
+ unknown `encoding=` raises `LookupError` from `os.fdopen` itself --
79
+ both happen strictly between `mkstemp` creating the temp and the
80
+ rename, so both must unlink it too, or it leaks into the caller's
81
+ directory un-unlinked.
82
+ """
83
+ # `content.encode(encoding)` raises `UnicodeEncodeError` (a `ValueError`)
84
+ # on unencodable content, or `LookupError` on an unknown `encoding=` --
85
+ # both used to surface later, from inside `os.fdopen`/`handle.write`,
86
+ # strictly between `mkstemp` creating the temp and the rename, which is
87
+ # why the old single-function version needed its own cleanup `except`
88
+ # for them. Encoding FIRST means either error now raises before any temp
89
+ # file exists at all, so there is nothing here left to clean up.
90
+ encoded = content.encode(encoding)
91
+ _atomic_write_bytes(path, encoded)
92
+
93
+
94
+ def atomic_write_bytes(path: str, data: bytes) -> None:
95
+ """`atomic_write_text`'s byte-exact sibling -- same symlink-safety,
96
+ durability and mode-preservation, with no text encode/decode round trip
97
+ in between.
98
+
99
+ **The one caller that needs this over `atomic_write_text`
100
+ (`bootstrap_cmd._undo_relocation`'s registry rollback, review #904 third
101
+ round, nit) is restoring a BYTE snapshot taken before this run's own
102
+ write** (`_read_global_sdk_registry_bytes`), not producing fresh
103
+ content -- and that snapshot is not guaranteed UTF-8: this registry
104
+ tolerates a hand-edited or pre-this-fix file (`parse_registry`'s own
105
+ "never raises on malformed content" contract), so decoding it to `str`
106
+ and back through `atomic_write_text` could raise on exactly the
107
+ corrupted-but-was-there-before content a rollback exists to put back
108
+ unchanged. Restoring the literal bytes, atomically, is the only shape
109
+ that can't fail on its own input.
110
+ """
111
+ _atomic_write_bytes(path, data)
112
+
113
+
114
+ def _atomic_write_bytes(path: str, data: bytes) -> None:
115
+ resolved = os.path.realpath(path)
116
+ directory = os.path.dirname(resolved)
117
+ existing_mode: int | None = None
118
+ try:
119
+ existing_mode = stat.S_IMODE(os.stat(resolved).st_mode)
120
+ except OSError:
121
+ pass
122
+ fd, tmp_path = tempfile.mkstemp(dir=directory, suffix=".tan-tmp")
123
+ try:
124
+ try:
125
+ handle = os.fdopen(fd, "wb")
126
+ except OSError:
127
+ # `fdopen` failing before a file object takes ownership of `fd`
128
+ # would otherwise leak the raw descriptor `mkstemp` opened.
129
+ # Best-effort: close if `fd` is still ours to close, ignore if
130
+ # not.
131
+ try:
132
+ os.close(fd)
133
+ except OSError:
134
+ pass
135
+ raise
136
+ with handle:
137
+ handle.write(data)
138
+ handle.flush()
139
+ os.fsync(handle.fileno())
140
+ os.replace(tmp_path, resolved)
141
+ except OSError:
142
+ try:
143
+ os.unlink(tmp_path)
144
+ except OSError:
145
+ pass
146
+ raise
147
+ if existing_mode is not None:
148
+ try:
149
+ os.chmod(resolved, existing_mode)
150
+ except OSError:
151
+ pass
152
+ else:
153
+ try:
154
+ # No race-free way to READ the process umask -- the standard
155
+ # idiom sets a harmless value and reads back the PREVIOUS one,
156
+ # then restores it immediately. `os.umask` is PROCESS-global, so
157
+ # this brief set-then-restore is safe only under tan's own
158
+ # single-threaded use of this helper; a second thread doing
159
+ # anything umask-sensitive inside this same window would observe
160
+ # -- or set -- the wrong value.
161
+ current_umask = os.umask(0o022)
162
+ os.umask(current_umask)
163
+ os.chmod(resolved, 0o666 & ~current_umask)
164
+ except OSError:
165
+ pass
166
+ if os.name != "nt":
167
+ try:
168
+ dir_fd = os.open(directory, os.O_RDONLY)
169
+ try:
170
+ os.fsync(dir_fd)
171
+ finally:
172
+ os.close(dir_fd)
173
+ except OSError:
174
+ # Best-effort insurance for the RENAME entry surviving a crash, on
175
+ # top of the file's own fsync above (which already protects the
176
+ # content): some filesystems/mounts (overlayfs, certain FUSE/NFS
177
+ # setups) refuse a bare directory fsync outright.
178
+ pass
@@ -0,0 +1,351 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Where a project's `board.yaml` is, and the one fact `tan scaffold` reports
3
+ out of it.
4
+
5
+ Two halves of the same question, and both had a copy-per-command problem:
6
+
7
+ * [`resolve_board_path`] is `validate_cmd._resolve_board_path`, MOVED here
8
+ rather than re-typed. `tan scaffold` needed exactly what `tan validate`
9
+ already does (tan-cli#1031), and this repo already carries SIX project/board
10
+ resolvers. A SEVENTH, written for `scaffold`, is precisely the drift
11
+ `tan.core.shapes`' own docstring is about; and it would be the same drift
12
+ alp-sdk-vscode#601/#633 had just finished deleting a second copy of THIS
13
+ generator to escape (the README's `## Wiring` section went missing on the
14
+ extension's side because a second copy existed at all). So the function
15
+ moved to a binding-free home both commands import.
16
+
17
+ **The criterion, stated so the next reader can CHECK the membership instead
18
+ of re-deriving it** -- this count has now been revised three times in a
19
+ docstring whose whole thesis is drift, which is its own argument for naming
20
+ the rule rather than the number. A member is a NAMED, REUSABLE function
21
+ under `python/tan/**` that turns the `--project`/`--board-yaml` pair into
22
+ the `board.yaml` path a command works from. Per-command inline joins do not
23
+ count; neither does anything under `tan/planner/**`, which resolves a board
24
+ DOCUMENT out of metadata rather than a path out of two flags. The six, with
25
+ the commands each serves:
26
+
27
+ tan/core/board_context.py:resolve_board_path validate, scaffold
28
+ presets_cmd.py:resolve_project_paths presets, pinmux, kconfig,
29
+ diff, clean, bootstrap
30
+ build_output.py:resolve_project_context size, image, model, west,
31
+ debug-config
32
+ inspect_cmd.py:resolve_debug_project_context inspect, trace,
33
+ support-bundle
34
+ flash_cmd.py:_resolve_project flash
35
+ generate_cmd.py:_resolve_board_path generate
36
+
37
+ FIVE of the six feed `tan.envelope.Project.resolved`, the reporting seam.
38
+ `generate_cmd._resolve_board_path` is the one that does not, deliberately:
39
+ `generate` reports the AS-GIVEN strings, built inline, because existence-
40
+ checking them against the real cwd is wrong the moment `--project` differs
41
+ from it (its own call site says so, citing tan-cli#236), and this resolver
42
+ answers only the path it READS. It is on the list anyway because it answers
43
+ the same question. A criterion of "uses `Project.resolved`" would have got
44
+ this exactly backwards on both ends: it would have EXCLUDED `generate_cmd`,
45
+ the one resolver that answers the question without the seam, while
46
+ ADMITTING a dozen things that are not resolvers at all -- measured at this
47
+ commit, there are 20 `Project.resolved(` call sites across 16 modules
48
+ (excluding the definition at `tan/envelope.py:222`), mostly command
49
+ callbacks doing an inline join. Hence the criterion above keys on what a
50
+ function DOES, not on which helper it happens to call.
51
+
52
+ Two earlier revisions of this paragraph were wrong in two different
53
+ directions -- one listed `generate_cmd`, the loosest match, while omitting
54
+ `inspect_cmd`, which does use the seam; the next inverted the reason above.
55
+ Three wrong revisions of one claim in a docstring whose subject is drift is
56
+ itself the argument for tan-cli#1091: prose does not hold a boundary, a
57
+ gate does.
58
+
59
+ The count did NOT go down: six before this change and six after, because
60
+ the sixth is validate's, relocated. Each is pinned to a different oracle's
61
+ reported shape, so they are distinct answers rather than duplicates, and
62
+ unifying them is not this change's job. What this change bought is that
63
+ `scaffold` did not make it seven.
64
+
65
+ **"One definition" here is now a gate, not prose (tan-cli#1091).**
66
+ `tests/gates/test_shared_helpers_have_one_definition.py` used to hard-assert
67
+ that every helper it owns lives in `tan/core/shapes.py`, reading its
68
+ ownership list out of that one file, so `resolve_board_path` could not join
69
+ it without generalising the gate from "shapes owns these" to a
70
+ `{name -> home module}` map. tan-cli#1091 did exactly that: the map is
71
+ `_SHARED_HELPERS` now, `resolve_board_path` is seeded in it pointed at this
72
+ module, and the per-helper assertion compares each entry against its OWN
73
+ declared home rather than a hard-coded `tan/core/shapes.py` literal. Its
74
+ sibling `tests/gates/test_shared_test_helpers_have_one_definition.py`
75
+ (tan-cli#1083) was already that shape and is what tan-cli#1091 copied.
76
+ NOT tan-cli#1081, which an earlier revision of this paragraph pointed at:
77
+ #1081's durable half already landed as #1083; the remainder of #1081's
78
+ scope (an outstanding `bound_sdk*` binder audit, tracked by PR #1102)
79
+ touches none of `TAN_ROOT`, `_SHARED_HELPERS`, or the per-entry home
80
+ assertion. A seventh private `_resolve_board_path` -- or a private
81
+ redefinition of this module's own --
82
+ now REDS `test_a_shared_helper_is_defined_exactly_once[resolve_board_path]`,
83
+ naming both files; this paragraph recorded the gap while it existed rather
84
+ than claiming it was already closed.
85
+
86
+ * [`read_board_context`] is the content of the generated module's
87
+ `// Board context: ...` line. Its wire spelling -- `<sku> / <os>`, with
88
+ [`UNSET`] for a half the document does not declare -- is the retired
89
+ alp-sdk-vscode generator's, kept verbatim so a module scaffolded from the
90
+ extension and one scaffolded by `tan` carry the same bytes.
91
+
92
+ **One spelling per envelope: the separator follows the ROOT** (tan-cli#1031,
93
+ found by CI on `windows-latest`; this box is Linux and cannot reproduce it).
94
+ [`resolve_board_path`] used to join the leaf with a hardcoded `/`, which is
95
+ right only while the root is posix-spelled. Given `--project C:\\w\\proj`
96
+ it answered the MIXED `C:\\w\\proj/board.yaml` while `project.root` stayed
97
+ `C:\\w\\proj` -- one envelope carrying two spellings of one directory, and
98
+ `C:\\w\\proj/board.yaml` is a spelling nothing contracts for and nobody
99
+ asked for. `tan validate` had emitted it since long before `scaffold` existed;
100
+ `scaffold`'s tests are simply the first to pass an absolute `--project`.
101
+
102
+ The invariant every OTHER reporter of this pair already satisfies is
103
+ `project.boardYaml == project.root + <sep> + <leaf>` -- asserted verbatim as
104
+ `f"{root}/board.yaml"` by `test_build_command.py:1182`,
105
+ `test_presets_command.py:702` and `test_inspect_command.py:163`. Those three
106
+ read `/` because THEIR root is posix-normalised upstream
107
+ (`build_output.to_posix`, `presets_cmd._posix`, `inspect_cmd._abs_posix`), NOT
108
+ because the leaf is posix by contract. So the fix is to derive the separator
109
+ from the root, not to posix the leaf.
110
+
111
+ `root` itself is deliberately NOT normalised here: `validate` and `scaffold`
112
+ report the string the caller TYPED (`generate` does the same), and re-spelling
113
+ it would be a behaviour change to a shipped command that no defect asks for.
114
+ A root with no separator at all (`"."`, `"proj"`, `"C:"`) keeps `/` on every
115
+ platform, so `"./board.yaml"` -- what every committed conformance golden
116
+ captures -- stays byte-identical across hosts. A root the caller spelled with
117
+ BOTH separators is left alone, `/` winning, so the answer is never more mixed
118
+ than the input.
119
+
120
+ `"C:"` is the one separator-less root where declining `ntpath.join` is not
121
+ merely a spelling difference but a DIFFERENT DIRECTORY, so it is called out
122
+ rather than left implicit (measured): this rule answers `C:/board.yaml`, the
123
+ root OF drive C:, while `ntpath.join("C:", "board.yaml")` answers
124
+ `C:board.yaml`, the drive-RELATIVE path naming the cwd ON C:. The input is
125
+ pathological -- `--project C:` is a drive letter, not a project -- and both
126
+ answers are defensible for it, but the divergence is real and a reader
127
+ checking this rule against the platform's own join deserves to meet it here
128
+ instead of discovering it. `tests/core/test_board_context.py` pins both
129
+ spellings.
130
+
131
+ The choice reads the root STRING, never `os.sep`, so the Windows branch is
132
+ fully exercised by a Linux run: `tests/core/test_board_context.py` drives it
133
+ against `ntpath.join`/`posixpath.join` as oracles. The Windows behaviour here
134
+ is therefore reasoned and unit-tested, not executed on Windows. Only
135
+ `os.path.isabs` stays host-aware, which is correct -- whether `C:\\x\\y` is
136
+ absolute genuinely IS a host question.
137
+
138
+ **What a reported path does and does not promise.** `tan.envelope.Project.
139
+ resolved` is the seam that turns a resolved path into `project.boardYaml`, and
140
+ it gates on `os.path.exists`, which is true for a DIRECTORY -- so a directory
141
+ named `board.yaml` reports a non-null path despite that method's own "only when
142
+ a file is really there". Pre-existing, and shared with `tan validate` and `tan
143
+ flash` (all three report through the same seam), so it is recorded here rather
144
+ than fixed asymmetrically in one command. It costs this module nothing:
145
+ [`read_board_context`] answers `unavailable` for that input either way, because
146
+ reading a directory raises and lands on the file-failure arm
147
+ (`test_a_directory_where_the_file_should_be_is_unavailable`).
148
+
149
+ **`board.yaml` ONLY, no SDK -- deliberately.** `tan scaffold` resolves no
150
+ alp-sdk checkout (its own module docstring says so, and one of tan-cli#1031's
151
+ four reported invocations passes no `--sdk-root` at all), and this module does
152
+ not change that. The consequence is stated rather than hidden: a v2
153
+ `board.yaml` that omits `os:` genuinely does not carry one -- alp-sdk's
154
+ `board.schema.json` says of `cores.<id>.os` that "The OS runtime is DERIVED
155
+ from the core's silicon class and is not selectable... Omit `os:` to take that
156
+ runtime", so the value then lives in the SoM preset, behind a checkout. That
157
+ half renders [`UNSET`]; it is not guessed from the core id. Resolving it would
158
+ mean walking `board.yaml -> som.sku -> metadata/e1m_modules/<sku>.yaml ->
159
+ silicon -> metadata/socs/**.json -> cores[].type ->
160
+ tan.core.os_class.default_os_from_core_type`, which would make a COMMENT's
161
+ bytes depend on which checkout happened to be discovered on the machine that
162
+ ran the scaffold. That is a bigger contract than a comment is worth; if it is
163
+ wanted it is a separate, argued change.
164
+ """
165
+
166
+ from __future__ import annotations
167
+
168
+ import os
169
+ from pathlib import Path
170
+ from typing import Any
171
+
172
+ #: What a resolved `board.yaml` renders for a field it does not declare. The
173
+ #: retired alp-sdk-vscode generator's own `?? "<unset>"` spelling
174
+ #: (`packages/alp-core/src/wizard/service.ts`, deleted in alp-sdk-vscode#601).
175
+ UNSET = "<unset>"
176
+
177
+ #: A `cores.<id>.os` value that PARKS a core rather than naming a runtime, so
178
+ #: it can never be the project's OS. Not a nicety: of the 100 example
179
+ #: `board.yaml` files in alp-sdk v0.16.0 (tag `v0.16.0`, `eb96112ba` -- an
180
+ #: earlier count of 99 here was measured off a stale `dev` working tree, not
181
+ #: the tag it cited), 53 declare `os:` at all and 51 of those declare ONLY
182
+ #: this -- reading the first declared value blindly would report `off` as the
183
+ #: board context for most real projects. Zero of the 100 carry a top-level
184
+ #: `os:`.
185
+ #:
186
+ #: The two that name a real runtime are the whole population this module can
187
+ #: answer without an SDK, so they are named rather than counted:
188
+ #: `examples/connectivity/modbus-server/board.yaml` (one core, `os: zephyr`)
189
+ #: and `examples/power-timing/power-managed-sensor/board.yaml` (`os: "off"`
190
+ #: on the parked HP core AND `os: zephyr` on the HE core -- the mixed shape
191
+ #: this constant exists for, and the one `test_a_parked_core_is_not_a_runtime`
192
+ #: pins). Every other example board renders the [`UNSET`] half.
193
+ OS_OFF = "off"
194
+
195
+ #: `cores.<id>.os` values that are declared but carry no runtime name. `""`
196
+ #: is not schema-valid (the enum is `zephyr|yocto|baremetal|off`); it is
197
+ #: rejected here anyway rather than being echoed into a C comment, since this
198
+ #: input is a hand-edited customer file and nothing upstream of here has
199
+ #: validated it.
200
+ _NOT_A_RUNTIME = ("", OS_OFF)
201
+
202
+
203
+ def resolve_board_path(project: str | None, board_yaml: str | None) -> tuple[str, str]:
204
+ """Return `(project_root, board_yaml_path)`, both as the CLI reports them.
205
+
206
+ Mirrors `resolve_offline_board_path`: the root defaults to the literal `"."`
207
+ and the board path stays RELATIVE, which the conformance fixtures pin
208
+ (`project.root == "."`, `boardYamlPath == "./board.yaml"`).
209
+
210
+ **`board_yaml` -- the `--board-yaml` flag -- WINS over the project-relative
211
+ default whenever it is given**: absolute, verbatim; relative, joined onto
212
+ the root. That is what its help text has always claimed ("Explicit
213
+ board.yaml path (overrides project resolution)") and what tan-cli#1031
214
+ found `tan scaffold` was not honouring, because `scaffold` never called a
215
+ resolver at all.
216
+
217
+ Existence is NOT checked here, and must not be: `validate`'s refusal
218
+ message names the path it could not open, so nulling it at the resolver
219
+ would strip the path out of the very message that names it.
220
+ `tan.envelope.Project.resolved` is the seam that checks -- its own
221
+ docstring documents that split.
222
+
223
+ The separator follows the ROOT's own spelling -- see "One spelling per
224
+ envelope" in the module docstring for why, and for what the alternatives
225
+ cost.
226
+ """
227
+ root = project if project else "."
228
+ if board_yaml and os.path.isabs(board_yaml):
229
+ return root, board_yaml
230
+ leaf = board_yaml or "board.yaml"
231
+ # Joined as STRINGS, not via pathlib: `Path(".") / "board.yaml"` normalises
232
+ # to `board.yaml`, but Rust's `Path::new(".").join("board.yaml")` keeps the
233
+ # `./`, and the conformance fixtures pin `"./board.yaml"`.
234
+ if root.endswith(("/", "\\")):
235
+ sep = ""
236
+ elif "\\" in root and "/" not in root:
237
+ sep = "\\"
238
+ else:
239
+ sep = "/"
240
+ return root, f"{root}{sep}{leaf}"
241
+
242
+
243
+ def _board_mapping(board_yaml_path: str | None) -> dict[str, Any] | None:
244
+ """`board.yaml` parsed to a mapping, or `None` for EVERY way that can
245
+ fail -- no path at all, a missing file, a directory, an unreadable one, a
246
+ non-UTF-8 byte, malformed YAML, an empty document, a document that parses
247
+ to something other than a mapping, or PyYAML not installed.
248
+
249
+ **Never raises**, and that is the contract, not an implementation detail:
250
+ `tan scaffold`'s job is to write a module, and it must still write one
251
+ when the `board.yaml` beside it is broken. `unavailable` is what the
252
+ generated line is FOR.
253
+
254
+ The broad `except` is deliberate rather than lazy. `UnicodeDecodeError` is
255
+ a `ValueError`, NOT an `OSError`, so an `except OSError` alone could never
256
+ catch it -- one undecodable byte in a customer's own board.yaml escaped
257
+ `tan model` as a traceback for exactly that reason (tan-cli#396). Nothing
258
+ upstream of here has validated this file.
259
+
260
+ PyYAML is a DECLARED BASE dependency (`python/pyproject.toml`'s
261
+ `dependencies = [... "pyyaml>=6" ...]`, which
262
+ `tests/gates/test_declared_dependencies.py` enforces) -- an earlier version
263
+ of this note claimed "tan ships no YAML dependency of its own", which is
264
+ simply false. The `except ImportError` is still right, for the reason
265
+ `tan.core.system_manifest._import_yaml` gives for its own copy: a frozen
266
+ `tan` built from a stale venv. It answers "nothing resolved" rather than a
267
+ coded envelope only because this is a comment line, not a manifest read.
268
+ """
269
+ if not board_yaml_path:
270
+ return None
271
+ try:
272
+ import yaml # noqa: PLC0415 (optional at runtime, by design)
273
+ except ImportError:
274
+ return None
275
+ # `Path(...)` is built OUTSIDE the `try` on purpose. The backstop below is
276
+ # for a BROKEN FILE; a `board_yaml_path` that is not path-like at all is a
277
+ # caller bug, and swallowing its `TypeError` here would report "no board
278
+ # context" for a defect in `tan`'s own code. It is also what makes the
279
+ # `if not board_yaml_path` guard above load-bearing rather than decorative:
280
+ # measured, with the try widened to cover this line, deleting that guard
281
+ # left `test_no_path_at_all_is_unavailable` GREEN, because `Path(None)`'s
282
+ # `TypeError` landed on the file-failure arm and answered `None` by
283
+ # accident (tan-cli#1031's own mutation run, mutant G3).
284
+ path = Path(board_yaml_path)
285
+ try:
286
+ document = yaml.safe_load(path.read_text(encoding="utf-8"))
287
+ except Exception: # noqa: BLE001 -- the customer's hand-edited file, not ours
288
+ return None
289
+ return document if isinstance(document, dict) else None
290
+
291
+
292
+ def _declared_os(board: dict[str, Any]) -> str | None:
293
+ """The OS this `board.yaml` DECLARES, or `None` when it declares none.
294
+
295
+ Two shapes, because `board.yaml` has had two. A v1 document carries a
296
+ top-level `os:` -- the field the retired alp-sdk-vscode generator read
297
+ (`boardModel.os`) and the one that produced the
298
+ `// Board context: E1M-AEN801 / zephyr` line tan-cli#1031 asks for. A v2
299
+ document has no top-level `os` at all (alp-sdk's `board.schema.json`
300
+ declares no such property, and the extension's own `normalizeBoardModel`
301
+ deleted it on the way out: "v2: top-level os: has no meaning"); v2's home
302
+ for the field is `cores.<id>.os`, which the second arm reads.
303
+
304
+ Cores that DISAGREE collapse to `None` rather than picking one. A
305
+ heterogeneous project (`a55_cluster: yocto` + `m33_sm: zephyr`) has no
306
+ single OS, and a module scaffold is never told which core it is for
307
+ (`plan_module_files` receives the template definition and the name,
308
+ nothing else) -- so there is nothing to choose with.
309
+
310
+ `None` is also the honest answer for the common v2 project that declares
311
+ no `os:` anywhere; see the module docstring for why that is not resolved
312
+ from the SDK here.
313
+ """
314
+ top = board.get("os")
315
+ if isinstance(top, str) and top not in _NOT_A_RUNTIME:
316
+ return top
317
+ cores = board.get("cores")
318
+ if not isinstance(cores, dict):
319
+ return None
320
+ declared = {
321
+ entry["os"]
322
+ for entry in cores.values()
323
+ if isinstance(entry, dict)
324
+ and isinstance(entry.get("os"), str)
325
+ and entry["os"] not in _NOT_A_RUNTIME
326
+ }
327
+ return declared.pop() if len(declared) == 1 else None
328
+
329
+
330
+ def read_board_context(board_yaml_path: str | None) -> str | None:
331
+ """`"<som.sku> / <os>"` for the generated `// Board context:` line, or
332
+ `None` when there is no board context to report -- the caller then keeps
333
+ the `unavailable` placeholder that line has always carried, now meaning
334
+ "genuinely none" rather than "never looked" (tan-cli#1031).
335
+
336
+ **`som.sku` is the gate, not a field that may go [`UNSET`].** It is the one
337
+ value alp-sdk's `board.schema.json` requires at the top level (`required:
338
+ [som, cores]`, and `som.required: [sku]`), so a document without a usable
339
+ one is not a board -- reporting `<unset> / <unset>` for it would dress a
340
+ broken file up as a resolved board, and `project.boardYaml` would still
341
+ name the file for anyone who wants to look. Every other half may be
342
+ `<unset>`; see [`_declared_os`].
343
+ """
344
+ board = _board_mapping(board_yaml_path)
345
+ if board is None:
346
+ return None
347
+ som = board.get("som")
348
+ sku = som.get("sku") if isinstance(som, dict) else None
349
+ if not isinstance(sku, str) or not sku:
350
+ return None
351
+ return f"{sku} / {_declared_os(board) or UNSET}"
@@ -0,0 +1,111 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Diagnostic data type + Rust-style renderer for the in-process board validator.
3
+
4
+ PORTED from alp-sdk `scripts/alp_cli/diagnostic.py` (pinned by
5
+ `tests/gates/test_planner_relocation_freshness.py::HAND_PORT_HASHES`).
6
+
7
+ One deliberate departure: the SDK file takes its colour from `colorama`
8
+ (`Fore.RED`, `Style.RESET_ALL`, ...), an undeclared optional dependency that
9
+ `colorama.init()` additionally wraps `sys.stderr` with, stripping every escape
10
+ when stderr is not a tty. tan declares neither, so the same three SGR
11
+ sequences are spelled out here. The in-process engine always renders with
12
+ `color=False` (`board_validator_run`): the bytes the SDK script's stderr carries
13
+ under a pipe, which is the only way tan ever ran it.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import sys
20
+ from collections.abc import Iterator
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+ from typing import Literal
24
+
25
+ Severity = Literal["error", "warning", "note"]
26
+
27
+ # colorama's Fore.RED / Fore.YELLOW / Fore.CYAN / Style.RESET_ALL.
28
+ _RED = "\x1b[31m"
29
+ _YELLOW = "\x1b[33m"
30
+ _CYAN = "\x1b[36m"
31
+ _RESET = "\x1b[0m"
32
+
33
+
34
+ @dataclass(slots=True)
35
+ class Diagnostic:
36
+ severity: Severity
37
+ path: Path
38
+ line: int
39
+ col: int
40
+ span: int
41
+ code: str
42
+ message: str
43
+ hint: str | None = None
44
+ doc_url: str | None = None
45
+
46
+
47
+ def _doc_url(code: str) -> str:
48
+ base = os.environ.get("ALP_DIAG_BASE_URL", "docs/diagnostics")
49
+ return f"{base}/{code}.md"
50
+
51
+
52
+ def _use_color(color: bool | None) -> bool:
53
+ if color is False:
54
+ return False
55
+ if color is True:
56
+ return True
57
+ if os.environ.get("NO_COLOR"):
58
+ return False
59
+ return sys.stdout.isatty()
60
+
61
+
62
+ def render(diag: Diagnostic, source_text: str, color: bool | None = None) -> str:
63
+ """Render a diagnostic as a multi-line Rust-style block."""
64
+ use_color = _use_color(color)
65
+
66
+ def paint(s: str, hue: str) -> str:
67
+ return f"{hue}{s}{_RESET}" if use_color else s
68
+
69
+ sev_hue = {"error": _RED, "warning": _YELLOW, "note": _CYAN}[diag.severity]
70
+ header = f"{paint(f'{diag.severity}[{diag.code}]', sev_hue)}: {diag.message}"
71
+ arrow = f" --> {diag.path}:{diag.line}:{diag.col}"
72
+
73
+ lines = source_text.splitlines()
74
+ if 1 <= diag.line <= len(lines):
75
+ src_line = lines[diag.line - 1]
76
+ else:
77
+ src_line = ""
78
+
79
+ gutter_w = max(2, len(str(diag.line)))
80
+ blank_gutter = " " * gutter_w
81
+ src_block = (
82
+ f"{blank_gutter} |\n"
83
+ f"{str(diag.line).rjust(gutter_w)} | {src_line}\n"
84
+ f"{blank_gutter} | {' ' * (diag.col - 1)}{paint('^' * max(1, diag.span), sev_hue)}"
85
+ )
86
+
87
+ tail: list[str] = []
88
+ if diag.hint:
89
+ tail.append(f"{blank_gutter} = hint: {diag.hint}")
90
+ tail.append(f"{blank_gutter} = see: {diag.doc_url or _doc_url(diag.code)}")
91
+
92
+ return "\n".join([header, arrow, src_block, *tail, ""])
93
+
94
+
95
+ class DiagnosticCollector:
96
+ """Collect diagnostics across multiple validation passes."""
97
+
98
+ def __init__(self) -> None:
99
+ self._items: list[Diagnostic] = []
100
+
101
+ def add(self, diag: Diagnostic) -> None:
102
+ self._items.append(diag)
103
+
104
+ def __iter__(self) -> Iterator[Diagnostic]:
105
+ return iter(self._items)
106
+
107
+ def __len__(self) -> int:
108
+ return len(self._items)
109
+
110
+ def has_errors(self) -> bool:
111
+ return any(d.severity == "error" for d in self._items)