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,723 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """The malformed-document register, in ONE place both catalog readers import.
3
+
4
+ tan-cli#1084. `metadata/templates/catalog-v1.json` is read by TWO deliberate
5
+ implementations -- `tan/planner/template.py` (`load_catalog` +
6
+ `find_template_by_cores`) and `tan/core/example_catalog.py`
7
+ (`unsupported_som` + `find_example_by_cores`) -- and they cannot be collapsed
8
+ into one: `tan.planner.paths` binds `REPO = sdk_root()` at MODULE scope, so
9
+ importing anything under `tan.planner` before `bind_sdk_root` has run raises
10
+ `PlannerRootError`, and `tan init`'s whole SDK-free path (invariant I-32) must
11
+ keep working with no checkout bound at all. `example_catalog.py`'s own module
12
+ docstring carries the long form of that reasoning.
13
+
14
+ PR #1073 extracted the register (`require_mapping_doc` / `require_field`) and
15
+ PR #1082 extended it (`require_key`, plus the catalog's own two readers) --
16
+ but only inside `tan/planner/template.py`, because that was the module those
17
+ issues named. The result was the more dangerous half of a duplicated read:
18
+ after #1082 the planner REFUSED a malformed catalog with a curated error while
19
+ `example_catalog.py` still crashed raw (`json.JSONDecodeError`,
20
+ `AttributeError`, `TypeError`, `KeyError`, `FileNotFoundError`) or silently
21
+ mis-read it -- and `tests/gates/
22
+ test_example_catalog_cores_selector_agrees_with_planner.py`, which asserts the
23
+ two agree, covered only WELL-FORMED input, so the divergence was invisible to
24
+ CI. Two implementations of one read that agree on good input and diverge on
25
+ bad, with a test asserting they agree.
26
+
27
+ So the register moved here, to a module with no `tan.planner` in its import
28
+ closure and nothing but the standard library in it. It is a MOVE, not a copy:
29
+ `template.py` binds the same objects (`_require_field = _GUARDS.require_field`
30
+ and friends) so its ~40 call sites are byte-identical to what #1073/#1082
31
+ landed, and neutering one method here reds tests across BOTH readers and all
32
+ four documents. That is the proof the two are on one register rather than two
33
+ that happen to agree today.
34
+
35
+ WHAT `error` IS FOR
36
+ -------------------
37
+
38
+ The message shape is shared; the exception TYPE is not, and must not be.
39
+ `tan/planner/cli._emit_scaffold` catches `TemplateError` and nothing else,
40
+ while `tan/commands/init_cmd._plan_from_topology` catches
41
+ `CoresTopologyError` and nothing else -- so a register that raised one fixed
42
+ class would leave the other caller's curated error escaping as a traceback,
43
+ which is the very defect this family exists to close. `DocumentGuards` is
44
+ therefore constructed with the class its caller contracts for, once, at module
45
+ scope on each side.
46
+
47
+ THE MESSAGE REGISTER
48
+ --------------------
49
+
50
+ Three shapes, matching the register `tan/model/targets.py:312-323`
51
+ established -- each naming the FILE, the FIELD and the actual TYPE:
52
+
53
+ malformed {what} at {path}: expected {noun}, got {type}
54
+ {doc} {field} must be {noun}, got {type}
55
+ {doc} {field} is missing required key {key!r}
56
+
57
+ `noun` on the first defaults to "a YAML mapping" -- byte-identical to what
58
+ tan-cli#1034/#1048/#1052 landed for the three YAML documents `template.py`
59
+ reads -- and the catalog's two readers pass "a JSON object", because
60
+ `catalog-v1.json` is JSON and calling its top level a YAML mapping would be a
61
+ curated message that is not true (tan-cli#1077).
62
+
63
+ `require_key` adds EXACTLY the check the other two cannot express -- the key
64
+ is present -- and DELEGATES both type checks to `require_field`: the container
65
+ must be a mapping before `key in` means anything, and the value must be @kind
66
+ before the caller indexes it. One definition of the type half, one new message
67
+ (the missing-key line). Its @kind is optional because `$defs/parameter`'s
68
+ `default:` is the one schema-required key with NO declared type -- any JSON
69
+ value is legal there -- so `kind=None` requires presence only, never a shape
70
+ the schema does not.
71
+
72
+ `require_readable_text` (tan-cli#1085) is the FOURTH shape: not a type check
73
+ at all, but the curated-raise half of "read this file's bytes", shared by
74
+ `read_catalog_document` below and by `tan/planner/template.py::
75
+ render_to_envelope`'s template-example `board.yaml` read. Both used to run
76
+ the identical `except (OSError, UnicodeDecodeError)` body by hand -- one
77
+ inside this module, one still in `template.py` after tan-cli#1116 fixed its
78
+ narrowed `except OSError` to match -- naming a different `what` but otherwise
79
+ byte-for-byte the same. `read_catalog_document` now calls it instead of
80
+ inlining the read, so the two curated "cannot read X at Y: Z" messages this
81
+ module produces come from one definition instead of two that happened to
82
+ still agree.
83
+
84
+ HOW AN ABSENT FIELD IS NORMALISED IS THE CALLER'S DECISION (tan-cli#1052
85
+ review), and `require_field` deliberately checks EXACTLY what it is handed.
86
+ The two callers in `template.py` differ on purpose and both are pinned by live
87
+ tests:
88
+
89
+ * The example `board.yaml` sites normalise **`None` only** --
90
+ `[] if raw is None else raw`. An absent field and an explicit `null` pass; a
91
+ PRESENT but illegal falsy scalar (`pins: 0`, `pins: false`, `pins: ''`,
92
+ `cores: 0`) is refused, because it is just as illegal against
93
+ `board.schema.json` as `pins: 3`, and refusing one while silently emptying
94
+ the other is an asymmetry nothing would pin.
95
+ * The SoM-preset and board-metadata sites keep their pre-existing
96
+ `doc.get(field) or <empty>`, which collapses EVERY falsy value before this
97
+ helper sees it, so `topology: 0` / `e1m_routes.gpio: 0` still degrade
98
+ silently. That asymmetry is tan-cli#1048's own recorded decision, pinned by
99
+ `test_board_route_entries_malformed_board.py::
100
+ test_a_falsy_scalar_section_value_still_degrades_silently`; changing it is a
101
+ behaviour change to two documents #1052 does not name. It is also defensible
102
+ on BEHAVIOUR: there a falsy section degrades to `[]` and is then caught by
103
+ `_resolve_pin_target`'s curated `has no unambiguous 'board_alias:'`, so
104
+ nothing silently wrong escapes -- where `pins: 0` reached NO downstream
105
+ guard at all. Remove that catch and the asymmetry stops being correct.
106
+
107
+ The catalog sites use the sentinel form `record.get(key, [])` they already
108
+ had: absent degrades to `[]`, present-but-`null` is refused as `got NoneType`.
109
+
110
+ NOT STRICTER THAN THE SCHEMAS. Every key `require_key` is used on is
111
+ `required` in `metadata/schemas/template-catalog-v1.schema.json`, and
112
+ `require_field`'s `pins:`-style sites are `type: array` with no `minItems`, so
113
+ an empty list still renders -- rejecting it would be a new refusal, not a
114
+ fixed crash.
115
+
116
+ THE MEMBERSHIP BAR, AMENDED (tan-cli#1133, PR #1160 review)
117
+ -----------------------------------------------------------
118
+
119
+ The bar stated below -- "a shape RUN BY more than one consumer module that
120
+ can be unified without changing any consumer's pinned contract" -- is a
121
+ SUFFICIENT condition, not a necessary one, and #1133 is where that
122
+ distinction had to be made explicit. `require_yaml_mapping_doc`,
123
+ `read_yaml_mapping` and `require_readable_bytes` each have exactly ONE
124
+ consumer module today (`tan/planner/template.py`; `example_catalog.py` reads
125
+ JSON only and decodes nothing). Read literally, the bar excludes all three,
126
+ and the first cut of #1133 duly defined the two YAML ones as module-level
127
+ functions inside `template.py`.
128
+
129
+ They are here instead, on a SECOND sufficient condition this module already
130
+ embodied without ever writing down -- FORMAT SYMMETRY: **when this register
131
+ already answers a question for one serialisation, the same question for
132
+ another serialisation belongs beside it, not in a consumer.**
133
+ `read_catalog_document` is read + parse + shape-check for JSON.
134
+ `read_yaml_mapping` is read + parse + shape-check for YAML. Splitting those
135
+ across two modules would have meant a reader asking "where does this repo
136
+ decide whether a document is the shape it claims to be?" getting two
137
+ different answers depending on the file extension -- which is a smaller,
138
+ quieter version of exactly the two-implementations-of-one-read problem
139
+ tan-cli#1084 created this module to end.
140
+
141
+ Two arguments that were made for keeping them in `template.py`, recorded
142
+ because one of them was simply WRONG and a future round should not re-derive
143
+ it as if it were new:
144
+
145
+ * "The register is standard-library-only, so `import yaml` would put PyYAML
146
+ in the import closure of `tan init`'s SDK-free path." FALSE as stated: it
147
+ is true only of a MODULE-SCOPE import, and this package has an established
148
+ idiom that defeats it. Seven `tan/core/**` modules already defer `import
149
+ yaml` into a function body -- `board_context.py`, `bootstrap.py`,
150
+ `doctor_libraries.py`, `som_buildability.py`, `scaffold.py`,
151
+ `system_manifest.py`, `flash_plan.py` -- and `som_buildability.py:109`
152
+ documents that exact reasoning. `require_yaml_mapping_doc` does the same,
153
+ so the property this module claims stays true and is now enforced by a
154
+ test rather than by the absence of the import.
155
+ * "One consumer, so extraction would be for its own sake." Answered by the
156
+ format-symmetry condition above, and by SIZE, which is not a tiebreaker
157
+ here but a real cost: at the time, `template.py` was 2206 lines -- the
158
+ SIXTH-largest module under `python/tan/` (`bootstrap_cmd.py` 4450,
159
+ `doctor_cmd.py` 4415, `flash_cmd.py` 3497, `bootstrap.py` 2558,
160
+ `flash_plan.py` 2273 all sat above it, measured at the commit that first
161
+ wrote this paragraph -- not the fourth-largest, nor the most oversized,
162
+ two claims earlier revisions of this same correction each made and each
163
+ got wrong the same way: naming only some of what actually sat above it)
164
+ -- and `_module_size_budget_core.MIRRORED_PREFIX`'s comment
165
+ ("upstream's to split, not this repo's") was read as barring a split of it
166
+ here. WRONG, corrected in tan-cli#1142 alongside the split that comment was
167
+ misread as forbidding: `MIRRORED_PREFIX` bars a `PINNED_HASHES` module's
168
+ shape from diverging from a moving upstream file it is 3-way-merged
169
+ against; `template.py` is `HAND_PORT_SOURCES` instead, flagged against
170
+ upstream and never merged, so it has no such cost (see
171
+ `tan/planner/template_pins.py`'s own docstring). This module is 400-odd
172
+ lines, well under the cap, and splittable regardless. Keeping 71 lines of
173
+ shared-shape code in a file everyone at the time believed could never shed
174
+ them, to satisfy a bar written to prevent gratuitous extraction, would have
175
+ been the letter of the rule against its purpose.
176
+
177
+ WHAT IS DELIBERATELY NOT HERE
178
+ ------------------------------
179
+
180
+ tan-cli#1085 closed the CROSS-MODULE half of this family that was still
181
+ duplicated by hand (`require_readable_text`, the read-half shape
182
+ `render_to_envelope`'s example `board.yaml` read shared with
183
+ `read_catalog_document`). It did not shrink `template.py` back toward its
184
+ pre-family size: **1548** lines at tan-cli#1001 (`156582e`, before #1025
185
+ opened this family at all) -> **1658** at the end of #1025/#1034/#1037/
186
+ #1048 -> `1780` (#1073, +122) -> `2156` (#1082, +376) -> `2020` (#1084,
187
+ -136) -> `2051 -> 2066` (smaller rounds since) -> `2063` (#1085, -3) ->
188
+ `2125` (rounds since) -> **`2219` (tan-cli#1133, +94)**. Net **+671** over
189
+ the true 1548-line baseline, re-measured rather than carried forward: the
190
+ `+515` this paragraph recorded at #1085 was true then and is not now, and a
191
+ count nobody re-measures is the same defect as a caller count nobody
192
+ re-measures (below). #1084 remains the only round that ever removed
193
+ anything. This module is the piece of that growth two CONSUMER modules
194
+ (`template.py` and `example_catalog.py`) both call into through the same
195
+ curated-raise contract, and no more:
196
+
197
+ * `_require_constraints` stays in `template.py`. It guards `$defs/parameter`'s
198
+ `constraints:` bounds, which only the planner's parameter resolution reads;
199
+ `example_catalog.py` has no parameter path at all, so moving it would be
200
+ extraction for its own sake with no second consumer to prove it shared.
201
+ tan-cli#1087 (PR #1123) kept `_check_constraints`'s `ParameterError` local
202
+ for the identical reason.
203
+ * `_require_mapping_doc`/`_require_field`/`_require_key`/`require_readable_
204
+ text` belong here. The bar is not "more than one caller" (this module's
205
+ OWN methods calling each other satisfy that trivially and prove nothing)
206
+ and not "a caller in a different module" either (`resolve_targets` below
207
+ never calls `_load_som_doc` at all -- it reimplements it -- so a rule
208
+ keyed on CALLING would wrongly admit that as sharing). The bar is a
209
+ MEMBERSHIP property: **a shape RUN BY more than one consumer module that
210
+ can be unified without changing any consumer's pinned contract.** All
211
+ four here clear it: `template.py` and `example_catalog.py` both run
212
+ `require_mapping_doc` (`template.py` directly; `example_catalog.py` only
213
+ via `read_catalog_document`/`catalog_templates`, which it calls directly
214
+ -- the delegation is load-bearing, confirmed by neutering `require_
215
+ mapping_doc` alone and watching `example_catalog`'s own suite red through
216
+ that path and no other). `require_readable_text` is the newest member and
217
+ the clearest case: the day before this change it was two INLINE copies,
218
+ one in this module and one in `template.py`, both already on the exact
219
+ same `self.error`-injected contract -- nothing to change to unify them,
220
+ which is why they did. tan-cli#1133 gave it a THIRD and FOURTH caller
221
+ (`template.py`'s two `metadata/**` reads, through `_read_yaml_mapping`)
222
+ and one optional argument, `absent`, which only `template.py` passes.
223
+ That asymmetry is deliberate and is NOT a second definition creeping
224
+ back in: the read itself, the `except` tuple and the `cannot read {what}
225
+ at {path}` message stay single-definition, and `absent` only chooses
226
+ which of two messages the ONE `FileNotFoundError` case gets -- those two
227
+ callers have a better one, naming the SKU or the board being resolved,
228
+ and `read_catalog_document` does not.
229
+ * `_load_som_doc` and `_board_route_entries` do NOT clear that bar, but not
230
+ because they lack a second caller -- they have two each, WITHIN
231
+ `template.py` (`_load_som_doc`: `_default_preset_for_sku` and
232
+ `_topology_for_sku`; `_board_route_entries`: `_board_alias_to_entry` and
233
+ `_resolve_pin_target`), which is exactly why each is its own function
234
+ instead of two inlined reads. The documents they read ARE read
235
+ independently elsewhere, but "independently" is the operative word --
236
+ none of the rivals below is RUN from this module or from `template.py`;
237
+ each is its own, separately-evolved implementation with its own
238
+ contract, so the membership bar excludes all of them even before the
239
+ "unify without changing a pinned contract" half is reached:
240
+ `tan/model/targets.py::resolve_targets` (`template.py`'s `_load_som_doc`
241
+ comment already names it) reimplements the `is_file()`-then-
242
+ `yaml.safe_load`-then-mapping-check shape `_load_som_doc` HAD until
243
+ tan-cli#1133 replaced it -- `targets.py:307-313` still carries it (pre-flight at :307, the raise at
244
+ :308, `yaml.safe_load` at :311, the mapping check at :312-313), and
245
+ the audit script's shape 3 does not report it, because its raises are
246
+ builtins (`FileNotFoundError`/`ValueError`) rather than a curated class;
247
+ a separate contract, not covered by this change -- and it raises a
248
+ bare `FileNotFoundError` for the missing-file case and a bare `ValueError`
249
+ for the malformed case. Only the `ValueError` half is pinned by a test
250
+ (`tests/model/test_targets_malformed_preset.py`, `match="expected a YAML
251
+ mapping"`/`match="malformed SoM preset"`); `grep -rn "no SoM preset for
252
+ SKU" python/tests/` is empty, so the `FileNotFoundError` half's exact
253
+ message is NOT test-pinned today -- only its TYPE is implied by
254
+ `resolve_targets`'s own signature and callers, so this claim covers the
255
+ half that is actually proven and no more. `tan/core/som_buildability.py`'s
256
+ `_safe_load_mapping`-backed read is a THIRD implementation, contracted to
257
+ return `None` on every failure instead ("Never raises"), seeded into
258
+ `tests/gates/test_never_raises_contract_holds.py`. `tan/planner/
259
+ loader.py` (the same eight-section `_ROUTE_SECTIONS` tuple, inline) and
260
+ `tan/planner/project_emit/bom_netlist.py` (the same `e1m_routes:`
261
+ flatten) reimplement `_board_route_entries`'s read too, each to a FOURTH
262
+ contract: neither raises OR returns `None` on a wrong shape -- both
263
+ silently SKIP the one malformed entry and keep the rest
264
+ (`bom_netlist.py:76,79,82`'s three `if not isinstance(...): continue`
265
+ filters; `loader.py:1078`'s `if isinstance(e1m, str) and isinstance(
266
+ macro, str):` value-level filter) -- because both consume a project
267
+ document already schema-validated upstream, a different situation from
268
+ `template.py`'s scaffold-time raw-metadata read. A silent per-entry skip
269
+ is not "no guard at all"; it is its own distinct contract, and one MORE
270
+ reason four independently-evolved readers cannot fold into one shared
271
+ primitive without changing at least one of them: `targets.py`'s
272
+ `FileNotFoundError`/`ValueError`, `som_buildability.py`'s whole-function
273
+ `None`, and `loader.py`/`bom_netlist.py`'s per-entry silent skip are
274
+ four genuinely different answers to "what happens on a malformed read",
275
+ and this register offers exactly one (`self.error`, injected once per
276
+ caller). Left local, with the real rivals named here instead of an
277
+ unqualified "no other module reads it" claim, for a follow-up to weigh
278
+ on purpose rather than by default. `_docs_ref` stays for a third reason
279
+ again: it has one caller and degrades to `"main"` rather than
280
+ curated-raising at all, a different shape from everything else in this
281
+ file.
282
+
283
+ Staying local is a scope claim, not a soundness one -- and tan-cli#1085
284
+ left that distinction load-bearing rather than theoretical. At #1085 both
285
+ functions still guarded their OWN absence with a pre-flight `is_file()`
286
+ and then read+parsed UNGUARDED (no `try` at all, so `audit_narrow_except_
287
+ contracts.py`'s too-narrow-a-`try` sweep could not see them) -- a real,
288
+ live gap of the SAME #1116 class `require_readable_text` closes for the
289
+ catalog and the example `board.yaml`, reachable through `emit_scaffold`'s
290
+ `except TemplateError` and nothing else. tan-cli#1133 CLOSED it: both now
291
+ read through `template.py::_read_yaml_mapping`, which calls
292
+ `require_readable_text` here (with `absent=`) and then a local YAML parse
293
+ guard, and neither carries a pre-flight any more. They are still local,
294
+ for the membership reason above and no other; what is gone is the
295
+ soundness debt that used to sit behind that scope claim. The audit script
296
+ grew a third shape, `absent-try`, in the same change, so a fourth
297
+ instance of this is findable rather than invisible.
298
+ * The `SkuNotSupportedError` / `CoresTopologyNotFoundError` selection
299
+ semantics stay with their own modules. This register answers "is this
300
+ document the shape it claims to be", never "which record did you mean".
301
+ """
302
+
303
+ from __future__ import annotations
304
+
305
+ import json
306
+ from collections.abc import Callable
307
+ from typing import Any
308
+
309
+ __all__ = ["SHAPE_NOUN", "DocumentGuards"]
310
+
311
+ #: `isinstance` kind -> the noun the curated message uses. Four shapes cover
312
+ #: every field guard on both sides: a mapping, a list, a scalar string, and --
313
+ #: since tan-cli#1077 reached `constraints.minimum`/`constraints.maximum`, the
314
+ #: only numeric fields anything here subscripts -- an integer. `bool`
315
+ #: satisfies `int` in Python, so `minimum: true` passes and behaves as
316
+ #: `minimum: 1`; that is a curated error rather than a crash, and JSON `true`
317
+ #: is refused upstream by the schema's own `"type": "integer"`.
318
+ SHAPE_NOUN: dict[type, str] = {
319
+ dict: "a mapping", list: "a list", str: "a string", int: "an integer"}
320
+
321
+
322
+ class DocumentGuards:
323
+ """The register, bound to the exception class ONE caller contracts for.
324
+
325
+ See the module docstring for the message shapes, the schema-strictness
326
+ claim, and why the exception type is a constructor argument rather than a
327
+ fixed class.
328
+ """
329
+
330
+ def __init__(self, error: type[Exception]) -> None:
331
+ self.error = error
332
+
333
+ def require_mapping_doc(
334
+ self, doc: Any, *, path: Any, what: str, noun: str = "a YAML mapping",
335
+ ) -> dict[str, Any]:
336
+ """The OUTER-document half: @doc parsed, but is it a mapping?
337
+
338
+ A document that parses (legal YAML/JSON) but is not a mapping
339
+ (illegal against its schema) must not reach the caller's
340
+ `.get(...)`, which raises a raw `AttributeError` a CLI user sees as
341
+ a traceback instead of a curated error naming the file and the
342
+ actual type.
343
+ """
344
+ if not isinstance(doc, dict):
345
+ raise self.error(
346
+ f"malformed {what} at {path}: expected {noun}, "
347
+ f"got {type(doc).__name__}")
348
+ return doc
349
+
350
+ def require_field(self, value: Any, kind: type, *, doc: Any, field: str) -> Any:
351
+ """The NESTED-field half: @field of @doc must be @kind before the
352
+ caller indexes or iterates it.
353
+
354
+ An outer `require_mapping_doc` says nothing about what is INSIDE the
355
+ mapping, and every round of this family found its next sibling
356
+ exactly one level in. This checks EXACTLY what it is handed --
357
+ normalising an absent field is the caller's decision, and the two
358
+ deliberately differ (module docstring).
359
+ """
360
+ if not isinstance(value, kind):
361
+ raise self.error(
362
+ f"{doc} {field} must be {SHAPE_NOUN[kind]}, got "
363
+ f"{type(value).__name__}")
364
+ return value
365
+
366
+ def require_key(
367
+ self, mapping: Any, key: str, kind: type | None = None, *,
368
+ doc: Any, field: str,
369
+ ) -> Any:
370
+ """The MISSING-KEY third: @key is present on @mapping, and its value
371
+ is @kind (or any value, when @kind is None).
372
+
373
+ The catalog is read by BARE SUBSCRIPT, so its failure mode is
374
+ `KeyError` -- exactly as raw a traceback as the
375
+ `AttributeError`/`TypeError` the YAML documents produce, but neither
376
+ other method can express it: one checks a whole document, the other
377
+ a value already in hand. Both type checks DELEGATE to
378
+ `require_field`, so the register keeps one definition of them.
379
+ """
380
+ self.require_field(mapping, dict, doc=doc, field=field)
381
+ if key not in mapping:
382
+ raise self.error(
383
+ f"{doc} {field} is missing required key {key!r}")
384
+ value = mapping[key]
385
+ if kind is None:
386
+ return value
387
+ return self.require_field(value, kind, doc=doc, field=f"{field}.{key}")
388
+
389
+ def require_readable_text(
390
+ self, path: Any, *, what: str,
391
+ absent: str | Callable[[], str] | None = None,
392
+ ) -> str:
393
+ """@path, decoded as UTF-8 text, or the injected curated error --
394
+ the read-half tan-cli#1085 pulled out of `read_catalog_document`
395
+ below once `render_to_envelope`'s example `board.yaml` read
396
+ (`tan/planner/template.py`) turned out to run the identical body by
397
+ hand, naming a different @what.
398
+
399
+ `except (OSError, UnicodeDecodeError)`, not a pre-flight `is_file()`,
400
+ so a present-but-unreadable path (a directory, a permissions error)
401
+ is named too, not only a missing one. `UnicodeDecodeError` is a
402
+ `ValueError`, NOT an `OSError` -- catching only `OSError` (tan-cli#1096
403
+ review, and tan-cli#1116's own repeat of the same trap in
404
+ `render_to_envelope`) let a non-UTF-8 document escape past a curated
405
+ `except` clause that thought it already covered every failure.
406
+
407
+ @absent (tan-cli#1133) is the caller's OWN message for the one
408
+ failure a pre-flight `is_file()` used to answer for itself: the file
409
+ is not there. `_load_som_doc`'s `no metadata/e1m_modules/<sku>.yaml
410
+ for sku <sku>` and `_board_route_entries`' `no metadata/boards/
411
+ <board>.yaml for board <board>` are both pinned by live tests
412
+ (`tests/planner/test_render_to_envelope_malformed_example_board.py`),
413
+ and both are more useful than a generic `cannot read`, because they
414
+ name the SKU or board the caller was resolving rather than only the
415
+ path it derived. Passing @absent is therefore what lets a caller DROP
416
+ its pre-flight -- the tan-cli#1127 trap, where `Path.is_file()`
417
+ itself raises `PermissionError` on 3.12.3/3.13.15 and returns `False`
418
+ on 3.14.7, so the same unreadable file was a raw traceback on two
419
+ interpreters and a curated-but-UNTRUE "no such file" on the third
420
+ (both measured on this tree before the fix).
421
+
422
+ @absent may also be a zero-argument CALLABLE, resolved only if the
423
+ `FileNotFoundError` branch is taken -- see `_unreadable` below.
424
+
425
+ ONLY `FileNotFoundError` takes @absent, deliberately. Every other
426
+ `OSError` means the path is there in some form and could not be read
427
+ as text -- a directory (`IsADirectoryError`), a parent that is a
428
+ regular file (`NotADirectoryError`), a denied mode
429
+ (`PermissionError`), a symlink loop (`ELOOP`) -- and for all of them
430
+ `cannot read {what} at {path}: {strerror}` is true where "no such
431
+ file" would not be. An `is_file()` pre-flight could not draw that
432
+ line at all: it answered `False` to every one of them.
433
+ """
434
+ try:
435
+ return path.read_text(encoding="utf-8")
436
+ except (OSError, UnicodeDecodeError) as exc:
437
+ raise self._unreadable(exc, path, what, absent) from exc
438
+
439
+ def require_readable_bytes(
440
+ self, path: Any, *, what: str,
441
+ absent: str | Callable[[], str] | None = None,
442
+ ) -> bytes:
443
+ """@path's RAW BYTES, or the injected curated error -- the same
444
+ contract as `require_readable_text` above for a caller that must not
445
+ decode (tan-cli#1133).
446
+
447
+ Its one real caller is `tan/planner/template.py::_rendered_bytes`,
448
+ which copies a template's `files.user_owned` entries verbatim so that
449
+ `alp_template.py render` and `--emit scaffold` hand back the same
450
+ bytes -- a template asset is not required to be text at all, and
451
+ `render()` writes whatever it read. Measured before this fix, on
452
+ 3.12.3, 3.13.15 and 3.14.7 alike: a `chmod 000` source escaped
453
+ `emit_scaffold` as a raw `PermissionError`, a deleted one as a raw
454
+ `FileNotFoundError`, and a directory in its place as a raw
455
+ `IsADirectoryError`.
456
+
457
+ NOT expressible as `require_readable_bytes` -> `.decode()` for the
458
+ text case, which is why these are two methods rather than one:
459
+ `Path.read_text` opens in TEXT mode and applies universal-newline
460
+ translation, so a CRLF document reaches the caller with `\\n`, while
461
+ `read_bytes().decode("utf-8")` preserves `\\r\\n`. Folding one into
462
+ the other would silently change what every existing caller of the
463
+ text half sees on a CRLF checkout -- exactly the kind of quiet
464
+ behaviour change this family exists to prevent.
465
+
466
+ `UnicodeDecodeError` cannot arise here (nothing is decoded), so the
467
+ `except` is `OSError` alone -- narrower than the text half's on
468
+ purpose, and narrow BECAUSE the failure surface is smaller, not
469
+ because the shape was copied without thinking.
470
+ """
471
+ try:
472
+ return path.read_bytes()
473
+ except OSError as exc:
474
+ raise self._unreadable(exc, path, what, absent) from exc
475
+
476
+ def _unreadable(
477
+ self, exc: BaseException, path: Any, what: str,
478
+ absent: str | Callable[[], str] | None,
479
+ ) -> Exception:
480
+ """The curated error for a failed read -- ONE definition of the
481
+ message, shared by both read halves above.
482
+
483
+ @absent, when the caller passed one, is used for `FileNotFoundError`
484
+ and nothing else: every other failure means the path is there in some
485
+ form and could not be read, so `cannot read ...` is true where "no
486
+ such file" would not be.
487
+
488
+ @absent may be a zero-argument CALLABLE as well as a string. The
489
+ rationale lives HERE, in the one function that resolves it and the
490
+ one both read halves and all four widened signatures funnel into,
491
+ rather than on `require_readable_text` above: on the signature it
492
+ would be four copies of one paragraph, and each copy would be a
493
+ place for the next edit to fall out of step with the resolution
494
+ rule it describes.
495
+
496
+ It is resolved HERE too -- inside the `FileNotFoundError` branch -- so a
497
+ caller whose absent-message is expensive or itself fallible does not
498
+ pay for it, or trip over it, on the paths that never use it
499
+ (tan-cli#1171 review). `libraries.load_manifest` is the caller that
500
+ needs it: its message ends `Available: <every manifest stem>`, and
501
+ building that list means LISTING `metadata/libraries/`. Eagerly, that
502
+ directory walk ran ahead of the guarded read on every call -- so with
503
+ `metadata/` itself denied it raised a raw `PermissionError` out of
504
+ `Path.is_dir()` on 3.12.3 and 3.13.15 while the guarded read gave the
505
+ curated message on 3.14.7, reinstating the exact tan-cli#1127 split
506
+ this family exists to remove (all three measured). Passed lazily, the
507
+ listing happens only after the read has already answered "not there",
508
+ which is the one branch it describes.
509
+ """
510
+ if absent is not None and isinstance(exc, FileNotFoundError):
511
+ return self.error(absent() if callable(absent) else absent)
512
+ return self.error(
513
+ f"cannot read {what} at {path}: "
514
+ f"{getattr(exc, 'strerror', None) or exc}")
515
+
516
+ def require_yaml_mapping_doc(
517
+ self, text: str, *, path: Any, what: str,
518
+ ) -> dict[str, Any]:
519
+ """@text parsed as YAML and known to be a mapping, or the injected
520
+ curated error -- the YAML twin of `read_catalog_document`'s
521
+ `json.JSONDecodeError` arm (tan-cli#1133).
522
+
523
+ `yaml.YAMLError` is neither an `OSError` nor a `ValueError`, so no
524
+ `except` clause on any of `tan/planner/template.py`'s three
525
+ outer-document YAML reads had ever covered it: measured through the
526
+ real `emit_scaffold` on 3.12.3, 3.13.15 and 3.14.7, a malformed
527
+ `metadata/e1m_modules/<sku>.yaml`, `metadata/boards/<board>.yaml` or
528
+ template-example `board.yaml` each raised a raw
529
+ `yaml.parser.ParserError` past a caller catching `TemplateError` and
530
+ nothing else.
531
+
532
+ The message is `read_catalog_document`'s verbatim with the format
533
+ name swapped -- `malformed {what} at {path}: not valid YAML
534
+ ({problem}, line L column C)` -- because the two answer the same
535
+ question about two serialisations of the same kind of document.
536
+ `problem`/`problem_mark` belong to `MarkedYAMLError` (a scanner or
537
+ parser error); a plain `YAMLError` with neither degrades to its own
538
+ one-line text rather than folding a multi-line dump into a curated
539
+ message.
540
+
541
+ `import yaml` is FUNCTION-LOCAL, not module-scope, and that is the
542
+ whole of what keeps this module's stated property true: nothing in
543
+ `tan init`'s SDK-free path calls this method, so PyYAML stays out of
544
+ that path's import closure. The idiom is this package's own, not an
545
+ invention here -- `som_buildability.py`, `board_context.py`,
546
+ `bootstrap.py`, `doctor_libraries.py`, `scaffold.py`,
547
+ `system_manifest.py` and `flash_plan.py` all defer it the same way,
548
+ and `som_buildability.py:109` documents the reasoning verbatim.
549
+ """
550
+ import yaml
551
+ try:
552
+ doc = yaml.safe_load(text)
553
+ except yaml.YAMLError as exc:
554
+ problem = getattr(exc, "problem", None) or str(exc).replace("\n", " ")
555
+ mark = getattr(exc, "problem_mark", None)
556
+ where = ("" if mark is None else
557
+ f", line {mark.line + 1} column {mark.column + 1}")
558
+ raise self.error(
559
+ f"malformed {what} at {path}: not valid YAML "
560
+ f"({problem}{where})") from exc
561
+ return self.require_mapping_doc(doc or {}, path=path, what=what)
562
+
563
+ def read_yaml_mapping(
564
+ self, path: Any, *, what: str,
565
+ absent: str | Callable[[], str] | None = None,
566
+ ) -> dict[str, Any]:
567
+ """A YAML document at @path, read AND parsed AND known to be a
568
+ mapping -- the exact composite `read_catalog_document` below already
569
+ is for JSON (tan-cli#1133).
570
+
571
+ `tan/planner/template.py`'s two `metadata/**` reads used to spell
572
+ this as a pre-flight `if not path.is_file(): raise` followed by a
573
+ completely bare `yaml.safe_load(path.read_text(encoding="utf-8"))` --
574
+ no `try` at any point, which is why the too-narrow-a-`try` sweep
575
+ could not see them.
576
+
577
+ The pre-flight is gone rather than supplemented: `Path.is_file()` is
578
+ itself the tan-cli#1127 trap -- against a `chmod 000` PARENT
579
+ directory it raises `PermissionError` on 3.12.3 and 3.13.15 and
580
+ returns `False` on 3.14.7, so one unreadable file was a raw traceback
581
+ on two interpreters and, on the third, a curated but FALSE "no such
582
+ file" (all four cells measured). Classifying on the real exception
583
+ gives one answer on all three: `FileNotFoundError` -> @absent,
584
+ every other read failure -> `cannot read ...`, an unparseable
585
+ document -> `malformed ...`.
586
+ """
587
+ return self.require_yaml_mapping_doc(
588
+ self.require_readable_text(path, what=what, absent=absent),
589
+ path=path, what=what)
590
+
591
+ def read_optional_text(self, path: Any, *, what: str) -> str | None:
592
+ """@path's text, or `None` when the document is NOT THERE AT ALL --
593
+ every other read failure still raises the injected curated error
594
+ (tan-cli#1162).
595
+
596
+ The one shape `require_readable_text` cannot express: a caller for
597
+ whom an absent document is a legal branch rather than a failure.
598
+ `tan/planner/topology.py::_core_os_choices` is its consumer -- a
599
+ `metadata_root` that carries no `schemas/` of its own (a synthetic
600
+ test root) falls back to the in-tree `BOARD_SCHEMA`, and that
601
+ fallback is the function's own documented contract, not an error
602
+ path.
603
+
604
+ Absent is `FileNotFoundError` OR `NotADirectoryError`, and the
605
+ second is not a widening for its own sake: `ENOTDIR` means an
606
+ ancestor of @path is a regular file, so the directory the caller
607
+ was looking in does not exist as a directory -- the same "this root
608
+ carries no such document" fact `ENOENT` reports, arrived at one
609
+ level up. Every OTHER `OSError` (`PermissionError`,
610
+ `IsADirectoryError`, `ELOOP`) and `UnicodeDecodeError` means the
611
+ document IS there and could not be read, which is a failure and
612
+ must not degrade to a silent fallback onto a DIFFERENT document.
613
+
614
+ That distinction is the whole reason this is a `try` rather than
615
+ the `is_file()` pre-flight it replaces. `Path.is_file()` answers
616
+ `False` to all of those alike where it answers at all, and on a
617
+ `chmod 000` parent it does not answer at all on two of the three
618
+ interpreters this repo supports (tan-cli#1127) -- so the pre-flight
619
+ version of this branch raised a raw `PermissionError` on 3.12.3 and
620
+ 3.13.15 and silently read the wrong schema on 3.14.7.
621
+ """
622
+ try:
623
+ return path.read_text(encoding="utf-8")
624
+ except (FileNotFoundError, NotADirectoryError):
625
+ return None
626
+ except (OSError, UnicodeDecodeError) as exc:
627
+ raise self._unreadable(exc, path, what, None) from exc
628
+
629
+ def require_json_mapping_doc(
630
+ self, text: str, *, path: Any, what: str, noun: str = "a JSON object",
631
+ ) -> dict[str, Any]:
632
+ """@text parsed as JSON and known to be a mapping, or the injected
633
+ curated error -- `read_catalog_document`'s own parse+shape arm,
634
+ pulled out so it is not the catalog's private property
635
+ (tan-cli#1162).
636
+
637
+ Extracted for the same FORMAT-SYMMETRY reason the module docstring
638
+ gives for `require_yaml_mapping_doc`, and in the same direction:
639
+ that method already answers "is this text the document it claims to
640
+ be" for YAML, and the JSON half was sitting inline in one
641
+ catalog-specific composite where a second JSON reader could not
642
+ reach it. `tan/planner/zephyr_board.py::_load_soc_spec` and
643
+ `tan/planner/topology.py::_core_os_choices` are that second and
644
+ third reader.
645
+
646
+ A MOVE, not a copy: `read_catalog_document` below now calls this,
647
+ so the `not valid JSON` message has one definition rather than two
648
+ that happen to agree. Its own messages are unchanged byte for byte
649
+ -- `what="template catalog"` and the default @noun reproduce
650
+ exactly what tan-cli#1084 landed.
651
+
652
+ No `doc or {}` here, deliberately, where `require_yaml_mapping_doc`
653
+ has one: an empty YAML document parses to `None` and legitimately
654
+ means "an empty mapping", while empty JSON text is a
655
+ `json.JSONDecodeError` and a literal `null` is a document that says
656
+ `null` -- refused as `got NoneType`, which is what the catalog has
657
+ always done.
658
+ """
659
+ try:
660
+ doc = json.loads(text)
661
+ except json.JSONDecodeError as exc:
662
+ raise self.error(
663
+ f"malformed {what} at {path}: not valid JSON "
664
+ f"({exc.msg}, line {exc.lineno} column {exc.colno})") from exc
665
+ return self.require_mapping_doc(doc, path=path, what=what, noun=noun)
666
+
667
+ def read_json_mapping(
668
+ self, path: Any, *, what: str,
669
+ absent: str | Callable[[], str] | None = None,
670
+ noun: str = "a JSON object",
671
+ ) -> dict[str, Any]:
672
+ """A JSON document at @path, read AND parsed AND known to be a
673
+ mapping -- exactly what `read_yaml_mapping` above is for YAML, and
674
+ what `read_catalog_document` below now IS (tan-cli#1162).
675
+
676
+ @absent behaves as it does on `require_readable_text`: it is the
677
+ caller's own message for `FileNotFoundError` and nothing else, and
678
+ passing one is what lets a caller drop an `is_file()` pre-flight
679
+ without losing the message that pre-flight used to produce.
680
+ `_load_soc_spec`'s `no SoC spec at <path>` is preserved that way,
681
+ byte for byte.
682
+ """
683
+ return self.require_json_mapping_doc(
684
+ self.require_readable_text(path, what=what, absent=absent),
685
+ path=path, what=what, noun=noun)
686
+
687
+ def read_catalog_document(self, path: Any) -> dict[str, Any]:
688
+ """`metadata/templates/catalog-v1.json`, decoded and known to be a
689
+ JSON object -- the read BOTH catalog readers do.
690
+
691
+ The file-read half is `require_readable_text` above; `json.
692
+ JSONDecodeError` is a `ValueError` too, so an `except TemplateError`
693
+ never caught it and a half-written catalog reached the user as a
694
+ traceback.
695
+
696
+ A one-line call into `read_json_mapping` since tan-cli#1162, which
697
+ pulled the read+parse+shape composite this function WAS out to
698
+ where a second and third JSON reader could use it. Its messages are
699
+ unchanged byte for byte; what changed is that they now have one
700
+ definition. Kept as a named method rather than inlined at its two
701
+ call sites because `what="template catalog"` is the fact those
702
+ callers must not have to spell twice.
703
+ """
704
+ return self.read_json_mapping(path, what="template catalog")
705
+
706
+ def catalog_templates(self, doc: Any, *, path: Any) -> list[Any]:
707
+ """`templates:` as a real list, on a catalog that is really a mapping.
708
+
709
+ Every `doc.get("templates", ...)` site on both sides iterated
710
+ whatever was there, and the `.get` itself assumed a mapping:
711
+ `templates: 3` was `TypeError: 'int' object is not iterable` and a
712
+ bare-list catalog was `AttributeError: 'list' object has no
713
+ attribute 'get'`.
714
+
715
+ ABSENT still degrades to `[]` -- the pre-existing default, which
716
+ each caller already answers with its own not-found error. Only a
717
+ PRESENT non-list is refused, so nothing that read before stops
718
+ reading.
719
+ """
720
+ self.require_mapping_doc(doc, path=path, what="template catalog",
721
+ noun="a JSON object")
722
+ return self.require_field(doc.get("templates", []), list,
723
+ doc=path, field="templates")