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,499 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Read the SDK scaffold catalog's declared SoM support for ONE example, and
3
+ (tan-cli PR #996 Part 2) select a catalog record BY its `cores:` hardware
4
+ topology instead of by id or example path.
5
+
6
+ `find_example_by_cores` below is `tan init`'s customer-facing counterpart to
7
+ alp-sdk#1652's `find_template_by_cores` (`scripts/alp_template.py`,
8
+ hand-ported into `tan/planner/template.py` for the freshness-gate audit and
9
+ `tan.planner_cli --emit scaffold --cores`'s parity mirror). It is
10
+ DELIBERATELY a second, small, standalone implementation rather than a call
11
+ into `tan.planner.template.find_template_by_cores` -- same reasoning as
12
+ `unsupported_som` below: `tan.planner.paths` binds `REPO = sdk_root()` at
13
+ MODULE scope, so importing anything under `tan.planner` before
14
+ `bind_sdk_root` has run raises `PlannerRootError`, and `init_cmd.py`'s whole
15
+ SDK-free path (`tan init --template ...`, no checkout in sight, invariant
16
+ I-32) must keep working. The topology match itself is `json.loads(...)` plus
17
+ a list scan and a dict-equality check -- duplicating that one screen of pure
18
+ logic costs far less than a bind-then-deferred-import dance for every `tan
19
+ init` invocation, template-selecting or not.
20
+
21
+ **Distinct flag, distinct name, on purpose (tan-cli#996 review).** `tan init`
22
+ already has a `--cores` flag (`init_cmd.py`) that SPLICES a companion core
23
+ onto an already-chosen template's board.yaml -- a different feature entirely
24
+ from choosing WHICH template to use by its hardware topology. The customer
25
+ surface for this module's function is `--topology`, never `--cores`: the
26
+ same flag spelling meaning two different things depending on whether
27
+ `--template`/`--from-example` was also given would be a documentation
28
+ problem forever, not a one-time naming choice. See `init_cmd.py`'s `init()`
29
+ option help text for both flags' cross-reference to the other.
30
+
31
+ tan-cli#890. `tan init --from-example` copies an example tree and, when
32
+ `--som` is given, retargets the copied `board.yaml` onto that SKU. It never
33
+ consulted the catalog, so it answered the opposite of the SDK for the same
34
+ question:
35
+
36
+ $ alp_project.py --emit scaffold --template multicore-mailbox --sku E1M-AEN301
37
+ alp_project: multicore-mailbox: sku 'E1M-AEN301' is not supported
38
+ (supported: ['E1M-AEN801']) rc=1
39
+ $ tan init --from-example multicore/mproc-mailbox --som E1M-AEN301
40
+ exitCode 0 | ok True | issues 0
41
+
42
+ Measured worse than same-family: `--som E1M-V2N101` retargets that dual-M55
43
+ Alif example onto a Renesas part whose topology is `['a55_cluster', 'm33_sm']`
44
+ -- so the written `board.yaml` declares `m55_hp` and `m55_he`, neither of
45
+ which exists there. `tan validate` does refuse it a command later
46
+ (`ALP-B007`), but `init` reported `ok: true` with no issue at all, and an IDE
47
+ scaffolding through this path shows the customer green.
48
+
49
+ WHY THIS READS THE CATALOG DIRECTLY RATHER THAN THROUGH `tan.planner`
50
+ ---------------------------------------------------------------------
51
+
52
+ `tan/planner/template.py` already has `load_catalog` and a
53
+ `SkuNotSupportedError`, and it is a hash-audited mirror of alp-sdk's
54
+ `scripts/alp_template.py` -- so it must not be edited here. Importing it is
55
+ also not free: `tan.planner.paths` binds `REPO = sdk_root()` at MODULE scope,
56
+ so any `tan.planner.*` import before `bind_sdk_root` raises
57
+
58
+ PlannerRootError: tan.planner was imported before `bind_sdk_root(...)`;
59
+ its metadata paths freeze at import time
60
+
61
+ (measured). Pulling that into `init_cmd` would either break every SDK-free
62
+ `tan init` path or need a bind-then-deferred-import dance, for what is
63
+ `json.loads(path.read_text())` plus a list scan. `load_catalog` is exactly
64
+ that one line, so nothing here duplicates planner LOGIC -- and the lookup
65
+ below is by `example` path, which the mirror does not do at all (it looks
66
+ records up by `id`).
67
+
68
+ WHY BOTH READS GO THROUGH `tan/core/document_guards.py` (tan-cli#1084)
69
+ ----------------------------------------------------------------------
70
+
71
+ A second implementation of a read is only safe while the two answer the same
72
+ question the same way, and this one had stopped. tan-cli#1073 extracted a
73
+ malformed-document register into `tan/planner/template.py` and tan-cli#1077
74
+ extended it to the catalog, so THAT reader refuses a malformed
75
+ `catalog-v1.json` with a curated error naming the file, the field and the
76
+ type -- while this one still decoded it through a bare `json.loads` and then
77
+ `.get`/subscripted the result. Re-derived on `dev@be3a44b6` before fixing
78
+ (`tests/core/test_example_catalog_malformed_catalog.py` carries the full
79
+ table): nine shapes crashed RAW here (`JSONDecodeError`, `AttributeError`,
80
+ `TypeError`, `KeyError`, `FileNotFoundError`) where the planner refused
81
+ cleanly, four more were SILENTLY MIS-READ, and two of them escaped
82
+ `unsupported_som`'s own written "Never raises" contract.
83
+
84
+ Worse than either: `tests/gates/
85
+ test_example_catalog_cores_selector_agrees_with_planner.py` asserts the two
86
+ selectors AGREE, and covered only well-formed input -- so the divergence was
87
+ invisible to CI. Two implementations of one read that agree on good input and
88
+ diverge on bad, with a test asserting they agree.
89
+
90
+ The register therefore MOVED to `tan/core/document_guards.py` -- a module with
91
+ no `tan.planner` in its import closure, so the constraint above still holds
92
+ and nothing here is a copy. `template.py` binds the same objects, so its call
93
+ sites are byte-identical to what #1073/#1077 landed, and neutering one method
94
+ there reds tests on BOTH sides. The message shapes, the exception TYPE each
95
+ side raises, and what deliberately did NOT move (tan-cli#1085) are all
96
+ documented in that module.
97
+
98
+ THE ONE REMAINING DIVERGENCE, AND THE TWO NEW REFUSALS (tan-cli#1084)
99
+ ----------------------------------------------------------------------
100
+
101
+ `find_example_by_cores` excludes a record with no (or a falsy) `example:`
102
+ from its match set; `find_template_by_cores` does not filter on that field at
103
+ all, because it returns the RECORD where this returns the PATH. So on a
104
+ hand-edited catalog whose matching record has no `example:`, the planner still
105
+ answers "found" and this answers "not found". That gap is dev's, is unreachable
106
+ against a schema-valid catalog (`example` is `required`, `pattern`
107
+ `^examples/...`), and is left ALONE here -- closing it would change this
108
+ function's documented contract and is a selection question, not the strictness
109
+ question #1084 is about. What changed is that it is no longer undocumented:
110
+ `test_the_example_field_is_the_one_documented_divergence` pins it by name.
111
+
112
+ Two refusals ARE new, both of documents the schema never accepted, and both
113
+ taken to MATCH the planner rather than invented here:
114
+
115
+ * the matched record's `id:` is resolved before the ambiguity test, as
116
+ `find_template_by_cores` has done since tan-cli#1077 -- so a unique match
117
+ with a missing or non-string `id:` refuses where dev returned it happily.
118
+ `id` is `required` on every record.
119
+ * the matched record's `example:` must be a string. dev ran it through
120
+ `str(...)`, so a record carrying `example: 3` resolved to the literal src
121
+ `'3'` and `tan init` went looking for a directory of that name -- silent,
122
+ and the worst outcome in the whole re-derived table.
123
+
124
+ WHY A WARNING AND NOT A REFUSAL
125
+ --------------------------------
126
+
127
+ This path has its own written precedent. `--from-example` used to REFUSE an
128
+ example with no `board.yaml`, and that "made `tan init --from-example`
129
+ unusable for nearly the whole AEN family, which is worse than the original
130
+ defect" -- so it warns and still writes every file
131
+ (`init.example-missing-board-yaml`). The same reasoning holds here:
132
+ `E1M-AEN301` really does carry both `m55_hp` and `m55_he`, so the scaffold it
133
+ would have produced is plausibly fine. What was wrong is that NOTHING
134
+ CHECKED, and that is what the warning fixes.
135
+
136
+ WHY `catalog_unreadable` EXISTS, AND WHAT IT COVERS (tan-cli#1101)
137
+ --------------------------------------------------------------------
138
+
139
+ `unsupported_som`'s own contract folds catalog-absent, catalog-unreadable,
140
+ catalog-malformed, unreadable-matching-record, no-record-for-this-example,
141
+ and sku-is-supported into one indistinguishable `None` -- exactly right for
142
+ "should `--from-example` refuse", wrong for "did the check even run".
143
+ `catalog_unreadable` is the second, narrower read that answers the second
144
+ question, so `--from-example --som` can warn "this check did not run"
145
+ instead of reporting `issues: []` on a check that never happened.
146
+
147
+ It reports two DIFFERENT shapes, and (tan-cli#1101 review MINOR) says so in
148
+ two DIFFERENT sentences rather than one that overstates the second:
149
+
150
+ * the catalog DOCUMENT itself could not be decoded, or its top-level shape
151
+ (a JSON object, a `templates:` list) does not match its schema --
152
+ non-UTF-8 bytes, a directory where a file was expected, a permissions
153
+ failure (tan-cli#1101 review BLOCKER: a `PermissionError` on a directory
154
+ the caller cannot even `stat()` used to escape a `catalog.exists()`
155
+ pre-flight raw, undoing the #1096 crash-safety this whole issue is
156
+ about -- there is no pre-flight now, matching
157
+ `document_guards.read_catalog_document`'s own "not a pre-flight
158
+ `is_file()`" reasoning), invalid JSON, a non-object document, a
159
+ non-list `templates:`. This is genuinely "could not be read".
160
+ * the document reads fine and a record's `example:` DOES match, but that
161
+ SAME record's `supported:` / `supported.som_skus:` is not the shape its
162
+ schema declares -- missing entirely, the wrong type, or (tan-cli#1101
163
+ review MINOR) an absent key masquerading as "no `supported.som_skus`"
164
+ in `unsupported_som`'s own docstring, which conflated it with the
165
+ genuinely-silent empty-list case. The document was readable; ONE
166
+ record's declared support was not. Calling that "could not be read"
167
+ sends a customer looking for a corrupt file that does not exist.
168
+
169
+ In both cases the SoM-support check for THIS example could not run, which
170
+ is `catalog_unreadable`'s whole question -- unlike a record for a DIFFERENT
171
+ example that cannot be read, still skipped rather than fatal
172
+ (`_declared_som_skus`'s own per-record-skip precedent, unchanged: a record
173
+ whose OWN `example:` is unreadable cannot be attributed to the example being
174
+ asked about, so it is simply not evidence either way), and unlike a
175
+ well-formed catalog with simply no record for this example at all, which is
176
+ `unsupported_som`'s "COMMON case, not an edge one" silence (66 example
177
+ directories, 9 catalogued templates) -- there was nothing to check there,
178
+ not a check that failed. A catalog that is ABSENT altogether is the same
179
+ silence, for a different reason: `unsupported_som`'s own long-standing "no
180
+ catalog in this checkout (an older SDK; --from-example worked there before
181
+ this gate and must keep working)" precedent -- warning on it would add noise
182
+ to every pre-catalog checkout's `--from-example --som`, not close a gap.
183
+ Distinguished from every OTHER `MalformedCatalogError` by `err.__cause__`
184
+ rather than a pre-flight stat: `read_catalog_document`'s
185
+ `except (OSError, UnicodeDecodeError) as exc: raise self.error(...) from
186
+ exc` is the only site in the whole guarded read that chains a
187
+ `FileNotFoundError` as the cause, so `isinstance(err.__cause__,
188
+ FileNotFoundError)` names exactly "the file was never there" and nothing
189
+ else -- a directory, a permissions failure, and every record-level shape
190
+ error all chain something else (or nothing).
191
+ """
192
+
193
+ from __future__ import annotations
194
+
195
+ from pathlib import Path
196
+ from typing import Any
197
+
198
+ from tan.core.document_guards import DocumentGuards
199
+
200
+ #: The catalog, relative to an alp-sdk checkout root.
201
+ CATALOG_RELATIVE = Path("metadata") / "templates" / "catalog-v1.json"
202
+
203
+
204
+ class CoresTopologyError(Exception):
205
+ """Base for every refusal this module's catalog reads raise -- never a
206
+ bare KeyError/ValueError/TypeError escapes to a caller. `init_cmd.py`
207
+ catches each subclass and turns it into an `InitError` with the right
208
+ issue code for that case."""
209
+
210
+
211
+ class MalformedCatalogError(CoresTopologyError, ValueError):
212
+ """`metadata/templates/catalog-v1.json` is not the shape its schema
213
+ declares -- unreadable, not JSON, not a JSON object, or a record/field
214
+ of the wrong type (tan-cli#1084).
215
+
216
+ A `ValueError` as well, so the curated message lands in the same family
217
+ as the register `tan/model/targets.py:312-323` established; a
218
+ `CoresTopologyError` as well, so a caller that catches only this
219
+ module's base still cannot be handed a raw traceback. Measured before
220
+ adding the second base: nothing between `find_example_by_cores` and
221
+ `init()` catches a bare `ValueError` (`init_cmd.py:846` and `:1119`
222
+ both do, and neither encloses `_plan_from_topology`'s call), so the MI
223
+ cannot make a refusal disappear into an unrelated handler --
224
+ `test_a_malformed_catalog_is_not_swallowed_by_an_unrelated_handler`
225
+ pins that.
226
+
227
+ `find_example_by_cores` RAISES it; `unsupported_som` catches it and
228
+ returns None, which is that function's whole documented contract.
229
+ """
230
+
231
+
232
+ #: This module's binding of the shared malformed-document register --
233
+ #: literally the same three checks + two catalog readers
234
+ #: `tan/planner/template.py` uses, differing only in the exception class each
235
+ #: side's caller contracts for (`init_cmd._plan_from_topology` catches
236
+ #: `CoresTopologyError`; `planner/cli._emit_scaffold` catches
237
+ #: `TemplateError`). Every curated message below is therefore byte-identical
238
+ #: to the planner's for the same malformed document, which is what
239
+ #: `tests/gates/test_example_catalog_cores_selector_agrees_with_planner.py`
240
+ #: now asserts directly rather than assuming.
241
+ _GUARDS = DocumentGuards(MalformedCatalogError)
242
+
243
+
244
+ def unsupported_som(sdk_root: Path, example_src: str, sku: str) -> tuple[str, ...] | None:
245
+ """The catalog's `supported.som_skus` for `example_src`, when `sku` is
246
+ NOT in it. `None` means "nothing to report" -- and that covers every
247
+ reason, deliberately:
248
+
249
+ * no catalog in this checkout (an older SDK; `--from-example` worked
250
+ there before this gate and must keep working),
251
+ * catalog present but unparseable, or not the shape expected,
252
+ * no record for this example -- the COMMON case, not an edge one: the
253
+ catalog declares 9 templates while `examples/aen/` alone holds 66
254
+ directories, so an undeclared example has no support set to check
255
+ and inventing one would be this defect pointed the other way,
256
+ * a record whose `supported.som_skus` is present as an empty list
257
+ (`[]`) -- NOT a missing or malformed `supported`/`som_skus` key
258
+ (tan-cli#1101 review MINOR corrected this bullet, which used to
259
+ read as covering both): those raise `MalformedCatalogError` same as
260
+ any other malformed field, so THIS function still folds them into
261
+ `None`, but `catalog_unreadable` (below, see its module-docstring
262
+ section) reports them as a check that could not run, not silence,
263
+ * `sku` is in the set.
264
+
265
+ Never raises: a scaffold must not fail because a catalog could not be
266
+ read. The caller reports the returned set as a warning.
267
+
268
+ tan-cli#1084: that contract was WRITTEN but not held -- measured on
269
+ `dev@be3a44b6`, `templates: 3` escaped as `TypeError: 'int' object is
270
+ not iterable` and a record with `supported: 3` as `AttributeError:
271
+ 'int' object has no attribute 'get'`. Both are now curated by the
272
+ shared register and caught here, so every listed reason really does
273
+ return None. No outcome that was already `None` or a support set
274
+ changes; only the two that raised.
275
+ """
276
+ catalog = sdk_root / CATALOG_RELATIVE
277
+ # The catalog spells it `examples/<src>`; `--from-example` takes `<src>`.
278
+ # Compared with `/` separators on both sides -- `example_src` is already
279
+ # validated as a plain relative path by the caller, and the catalog is
280
+ # authored with forward slashes regardless of host.
281
+ wanted = f"examples/{example_src.strip().strip('/')}"
282
+ try:
283
+ supported = _declared_som_skus(catalog, wanted)
284
+ except MalformedCatalogError:
285
+ return None
286
+ if not supported or sku in supported:
287
+ return None
288
+ return tuple(str(s) for s in supported)
289
+
290
+
291
+ def _declared_som_skus(catalog: Path, wanted: str) -> list[Any] | None:
292
+ """`supported.som_skus` of the record whose `example` is @wanted, or
293
+ None when no record claims it. Raises `MalformedCatalogError` for a
294
+ catalog-level shape problem, or for a malformed MATCH.
295
+
296
+ A record whose own `example` cannot be read is SKIPPED rather than
297
+ fatal -- the scan is looking for one specific path, and a record it
298
+ cannot read is not that record. That is exactly what dev's
299
+ `isinstance(record, dict)` + `str(record.get("example", ""))` coercion
300
+ did, kept deliberately so a malformed record cannot silence a warning
301
+ a well-formed one later in the list would have produced.
302
+ """
303
+ doc = _GUARDS.read_catalog_document(catalog)
304
+ for index, record in enumerate(_GUARDS.catalog_templates(doc, path=catalog)):
305
+ field = f"templates[{index}]"
306
+ try:
307
+ example = _GUARDS.require_key(record, "example", str,
308
+ doc=catalog, field=field)
309
+ except MalformedCatalogError:
310
+ continue
311
+ if example.strip().strip("/") != wanted:
312
+ continue
313
+ return _GUARDS.require_key(
314
+ _GUARDS.require_key(record, "supported", dict,
315
+ doc=catalog, field=field),
316
+ "som_skus", list, doc=catalog, field=f"{field}.supported")
317
+ return None
318
+
319
+
320
+ def catalog_unreadable(sdk_root: Path, example_src: str) -> str | None:
321
+ """Whether the SoM-support check for `example_src` could not run at all
322
+ -- see "WHY `catalog_unreadable` EXISTS, AND WHAT IT COVERS" above for
323
+ the full reasoning (tan-cli#1101). `None` for a catalog that is ABSENT
324
+ or that reads cleanly (whether or not it supports `example_src`); the
325
+ already-composed, correctly-led reason string otherwise -- "the SDK
326
+ scaffold catalog could not be read" only for a genuine document-level
327
+ failure, a different lead for a document that read fine but whose
328
+ matching record did not.
329
+ """
330
+ catalog = sdk_root / CATALOG_RELATIVE
331
+ try:
332
+ doc = _GUARDS.read_catalog_document(catalog)
333
+ _GUARDS.catalog_templates(doc, path=catalog)
334
+ except MalformedCatalogError as err:
335
+ if isinstance(err.__cause__, FileNotFoundError):
336
+ return None
337
+ return f"the SDK scaffold catalog could not be read ({err})"
338
+ wanted = f"examples/{example_src.strip().strip('/')}"
339
+ try:
340
+ _declared_som_skus(catalog, wanted)
341
+ except MalformedCatalogError as err:
342
+ return (
343
+ f"the SDK scaffold catalog's declared support for this "
344
+ f"example is not the shape its schema declares ({err})"
345
+ )
346
+ return None
347
+
348
+
349
+ # ---------------------------------------------------------------------------
350
+ # `--topology`: select a catalog record BY its cores: hardware topology
351
+ # ---------------------------------------------------------------------------
352
+
353
+
354
+ class CoresTopologyNotFoundError(CoresTopologyError):
355
+ """No catalog record's `cores:` topology exactly equals the requested
356
+ one. `known_topologies` names every topology the catalog DOES offer, the
357
+ same "never a bare error, always the candidates" spirit
358
+ `AmbiguousCoresTopologyError` follows for its own case."""
359
+
360
+ def __init__(self, cores: dict[str, str], known_topologies: list[tuple[tuple[str, str], ...]]):
361
+ self.cores = cores
362
+ self.known_topologies = known_topologies
363
+ super().__init__(
364
+ f"no catalog template with cores topology {cores!r} "
365
+ f"(known topologies: {known_topologies})"
366
+ )
367
+
368
+
369
+ class AmbiguousCoresTopologyError(CoresTopologyError):
370
+ """More than one catalog record shares the requested `cores:` topology.
371
+
372
+ The correct behaviour is to name every candidate and let the customer
373
+ choose with `--template`/`--from-example` -- never to silently pick the
374
+ first. This is NOT a hypothetical: measured against the live alp-sdk
375
+ v0.16.0 catalog, the single-core `{"m55_hp": "zephyr"}` topology (the
376
+ shape a single-M55 AEN scaffold is most likely to ask for) already
377
+ matches five templates (`diagnostics`, `iot`, `minimal`, `peripheral`,
378
+ `sensor`) -- see `tests/planner/test_find_template_by_cores.py::
379
+ test_the_live_catalog_is_ambiguous_for_a_single_m55_hp_zephyr_topology`.
380
+ """
381
+
382
+ def __init__(self, cores: dict[str, str], candidate_ids: list[str]):
383
+ self.cores = cores
384
+ self.candidate_ids = candidate_ids
385
+ super().__init__(
386
+ f"cores topology {cores!r} matches multiple catalog templates "
387
+ f"{candidate_ids} -- use --template or --from-example to "
388
+ f"disambiguate"
389
+ )
390
+
391
+
392
+ def _topology(record: Any, index: int, catalog: Path) -> dict[str, str]:
393
+ """One record's `cores:` topology (core id -> os), every shape checked
394
+ on the shared register.
395
+
396
+ A LINE-FOR-LINE mirror of `find_template_by_cores`'s own `_topology`
397
+ (`tan/planner/template.py`), down to the `templates[i].cores[j]` field
398
+ labels -- so a malformed record produces the byte-identical curated
399
+ message on both sides. tan-cli#1084: this was
400
+ `{c["id"]: c["os"] for c in record.get("cores") or ()}`, which raised
401
+ `KeyError`/`TypeError` on four shapes and silently mis-read a
402
+ non-string `id` into a topology that could never match.
403
+
404
+ `record.get("cores", [])`, not dev's `or ()`: an ABSENT `cores:` still
405
+ degrades to `{}` exactly as before, but a present `cores: null` is
406
+ refused as `got NoneType` rather than silently emptied -- the planner's
407
+ behaviour, and `cores` is `required` with `minItems: 1` in the schema,
408
+ so no catalog that was ever valid stops loading.
409
+ """
410
+ field = f"templates[{index}]"
411
+ _GUARDS.require_field(record, dict, doc=catalog, field=field)
412
+ entries = _GUARDS.require_field(record.get("cores", []), list,
413
+ doc=catalog, field=f"{field}.cores")
414
+ return {
415
+ _GUARDS.require_key(core, "id", str, doc=catalog,
416
+ field=f"{field}.cores[{j}]"):
417
+ _GUARDS.require_key(core, "os", str, doc=catalog,
418
+ field=f"{field}.cores[{j}]")
419
+ for j, core in enumerate(entries)}
420
+
421
+
422
+ def find_example_by_cores(sdk_root: Path, cores: dict[str, str]) -> str:
423
+ """The `--from-example`-compatible `src` (e.g. `multicore/mailbox`, no
424
+ leading `examples/`) whose catalog record's `cores:` topology is EXACTLY
425
+ `cores` -- `tan init --topology`'s resolution step.
426
+
427
+ Raises `CoresTopologyNotFoundError` (no exact match),
428
+ `AmbiguousCoresTopologyError` (more than one) or
429
+ `MalformedCatalogError` (the catalog is not the shape its schema
430
+ declares) -- all three refuse rather than guess, unlike
431
+ `unsupported_som` above, which is a best-effort warning path. There is
432
+ no project yet to scaffold without a real answer here: "which template"
433
+ is not a fact `init` can degrade silently on the way "is this SoM
434
+ supported" can.
435
+
436
+ A record with no declared `example` is excluded from the match set
437
+ rather than raising -- the ONE place the two selectors deliberately
438
+ still differ, and dev's own behaviour, kept rather than changed. It and
439
+ the two new refusals this function does add are set out under THE ONE
440
+ REMAINING DIVERGENCE in the module docstring.
441
+ """
442
+ catalog = sdk_root / CATALOG_RELATIVE
443
+ doc = _GUARDS.read_catalog_document(catalog)
444
+ indexed = list(enumerate(_GUARDS.catalog_templates(doc, path=catalog)))
445
+ topologies = {index: _topology(rec, index, catalog)
446
+ for index, rec in indexed}
447
+ matches = [(index, rec) for index, rec in indexed
448
+ if rec.get("example") and topologies[index] == cores]
449
+ if not matches:
450
+ known = sorted(
451
+ {tuple(sorted(topo.items())) for topo in topologies.values()}
452
+ )
453
+ raise CoresTopologyNotFoundError(cores, known)
454
+ ids = sorted(
455
+ _GUARDS.require_key(rec, "id", str, doc=catalog,
456
+ field=f"templates[{index}]")
457
+ for index, rec in matches)
458
+ if len(matches) > 1:
459
+ raise AmbiguousCoresTopologyError(cores, ids)
460
+ index, record = matches[0]
461
+ example = _GUARDS.require_key(record, "example", str, doc=catalog,
462
+ field=f"templates[{index}]")
463
+ return example.strip().strip("/").removeprefix("examples/")
464
+
465
+
466
+ def parse_topology_arg(raw: str) -> dict[str, str]:
467
+ """`--topology core_id:os[,core_id:os...]` -- STRICT `id:os` pairs, no OS
468
+ inference (unlike `--cores`' splice parser, `parse_cores`): a selector
469
+ must match a catalog record's topology exactly, so an inferred OS could
470
+ silently look up a topology the caller never actually typed. Mirrors
471
+ alp-sdk's `alp_project._parse_cores_arg` (the same parser
472
+ `find_template_by_cores`'s own CLI front door uses).
473
+
474
+ Raises `ValueError` on any malformed entry -- the caller wraps this in
475
+ an `InitError` (`init.invalid-topology`).
476
+ """
477
+ cores: dict[str, str] = {}
478
+ for entry in raw.split(","):
479
+ entry = entry.strip()
480
+ if not entry:
481
+ continue
482
+ if ":" not in entry:
483
+ raise ValueError(
484
+ f"--topology entry {entry!r} must be core_id:os (e.g. "
485
+ f"m55_hp:zephyr)"
486
+ )
487
+ core_id, _, os_name = entry.partition(":")
488
+ core_id, os_name = core_id.strip(), os_name.strip()
489
+ if not core_id or not os_name:
490
+ raise ValueError(
491
+ f"--topology entry {entry!r} must be core_id:os (e.g. "
492
+ f"m55_hp:zephyr)"
493
+ )
494
+ if core_id in cores:
495
+ raise ValueError(f"--topology names {core_id!r} more than once")
496
+ cores[core_id] = os_name
497
+ if not cores:
498
+ raise ValueError("--topology must name at least one core_id:os pair")
499
+ return cores
@@ -0,0 +1,151 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Per-example facets (`category`, `som`, `board`, `cores`, `coreCount`,
3
+ `osSet`, `declares`) read out of alp-sdk's generated `metadata/catalog.json`.
4
+
5
+ tan-cli#484: `tan examples` used to publish only
6
+ `id`/`sourceDir`/`title`/`description`, so no consumer could filter the
7
+ catalogue by core count, OS, or SoM -- not even by category, though
8
+ `examples/` has always been grouped into one. alp-sdk's companion issue
9
+ answered this by deriving exactly these facets through its topology resolver
10
+ (`scripts/gen_catalog.py::_resolved_core_facets`, which calls the SAME
11
+ `alp_project.py --emit os-topology` path `tan build` itself plans a project
12
+ against) and publishing them pre-computed in `metadata/catalog.json`
13
+ (`build_examples()`). Measured against alp-sdk `origin/dev`
14
+ (`20baaaf19`): the facets are already there.
15
+
16
+ **tan reads that generated JSON; it does not resolve topology itself.**
17
+ I-26 keeps `metadata/**` hardware-fact resolution in alp-sdk -- this module
18
+ is the `examples` analogue of `tan.core.error_catalog` reading
19
+ `metadata/error-catalog.json` rather than re-deriving diagnostics, and of
20
+ `tan.core.example_catalog` reading `metadata/templates/catalog-v1.json`
21
+ rather than re-resolving SoM support. A facet this module cannot find in the
22
+ catalogue is left OFF the entry, never guessed: `cores`/`coreCount`/`osSet`
23
+ resolve through topology and reproducing that logic here would be exactly
24
+ the drift ADR-0020 exists to prevent.
25
+
26
+ **Degrades silently, never refuses `tan examples`.** A checkout predating
27
+ `catalog.json`, or one where it fails to parse, still lists every example by
28
+ directory discovery exactly as before -- the catalogue is enrichment, not a
29
+ second source of truth for WHICH examples exist. Mirrors
30
+ `tan.core.example_catalog.unsupported_som`'s own "never raises, absence
31
+ means nothing to report" contract for the neighbouring
32
+ `templates/catalog-v1.json` reader.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import json
38
+ from dataclasses import dataclass
39
+ from pathlib import Path
40
+
41
+ #: `<sdk_root>/metadata/catalog.json`, generated by `scripts/gen_catalog.py`.
42
+ CATALOG_RELATIVE = Path("metadata") / "catalog.json"
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class ExampleFacets:
47
+ """One example's catalogue-derived facets. `category` is always known --
48
+ it is the catalogue's own grouping key, mirrored from the example's
49
+ parent directory -- every other field is optional, exactly as
50
+ `gen_catalog.py` leaves it: a key it has nothing to say for is omitted
51
+ from the source document entirely rather than emitted null or empty
52
+ (no `som:` on a portable example; `cores`/`coreCount`/`osSet` all
53
+ absent together when the board's topology did not resolve, e.g.
54
+ `SdkRevisionNotBuildable`)."""
55
+
56
+ category: str
57
+ som: str | None
58
+ board: str | None
59
+ cores: tuple[dict, ...] | None
60
+ core_count: int | None
61
+ os_set: tuple[str, ...] | None
62
+ declares: dict[str, bool] | None
63
+
64
+
65
+ def load_example_facets(sdk_root: Path) -> dict[str, ExampleFacets]:
66
+ """`{sourceDir: ExampleFacets}` for every example the bound checkout's
67
+ catalogue knows about, or `{}` when there is nothing usable to read.
68
+
69
+ Never raises: a missing file (older SDK), an unreadable one, invalid
70
+ JSON, or a document that is not shaped the way `gen_catalog.py` writes
71
+ it all collapse to "no facets this run" -- the caller already has a
72
+ complete example list from directory discovery and must not lose any of
73
+ it because the enrichment source is stale, absent, or malformed.
74
+ """
75
+ path = sdk_root / CATALOG_RELATIVE
76
+ try:
77
+ raw = path.read_text(encoding="utf-8")
78
+ except (OSError, UnicodeDecodeError):
79
+ return {}
80
+ try:
81
+ doc = json.loads(raw)
82
+ except ValueError:
83
+ return {}
84
+ if not isinstance(doc, dict):
85
+ return {}
86
+ by_category = doc.get("examples")
87
+ if not isinstance(by_category, dict):
88
+ return {}
89
+
90
+ facets: dict[str, ExampleFacets] = {}
91
+ for category, entries in by_category.items():
92
+ if not isinstance(category, str) or not isinstance(entries, list):
93
+ continue
94
+ for entry in entries:
95
+ if not isinstance(entry, dict):
96
+ continue
97
+ name = entry.get("name")
98
+ if not isinstance(name, str) or not name:
99
+ continue
100
+ # `discover_examples`' own `source_dir` shape (`_subdirectories`
101
+ # walk), always `/`-joined -- this is the join key the two sides
102
+ # meet on, not `entry["path"]` (which additionally carries the
103
+ # `examples/` prefix `gen_catalog.py` writes it with).
104
+ facets[f"{category}/{name}"] = _facets_from_entry(category, entry)
105
+ return facets
106
+
107
+
108
+ def _facets_from_entry(category: str, entry: dict) -> ExampleFacets:
109
+ som = entry.get("som")
110
+ board = entry.get("board")
111
+ cores = entry.get("cores")
112
+ core_count = entry.get("coreCount")
113
+ os_set = entry.get("osSet")
114
+ declares = entry.get("declares")
115
+
116
+ # Element-guarded, not just list/dict-guarded: `examples_cmd.py::as_dict`
117
+ # does `dict(core)` on every `cores` element it emits, and a non-mapping
118
+ # element (a hand-edited catalogue, or a future `gen_catalog.py`
119
+ # regression) would raise `ValueError` there with no envelope at all --
120
+ # exactly the crash this whole module exists to never let through.
121
+ # Malformed elements/pairs are dropped, mirroring the "skip the bad
122
+ # element, keep the good ones" shape already used one level up for
123
+ # `entries`/individual catalogue rows -- an all-bad list/dict collapses
124
+ # to an empty tuple/dict rather than `None`, same as a catalogue that
125
+ # genuinely declared nothing (`declares: {}` is a real, present
126
+ # `gen_catalog.py` shape, not a degradation).
127
+ return ExampleFacets(
128
+ category=category,
129
+ som=som if isinstance(som, str) else None,
130
+ board=board if isinstance(board, str) else None,
131
+ cores=(
132
+ tuple(c for c in cores if isinstance(c, dict)) if isinstance(cores, list) else None
133
+ ),
134
+ # `isinstance(x, bool)` is excluded first: `bool` is an `int`
135
+ # subclass in Python, so `coreCount: true` would otherwise pass the
136
+ # `int` check and publish a boolean where the contract promises a
137
+ # core count.
138
+ core_count=(
139
+ core_count
140
+ if isinstance(core_count, int) and not isinstance(core_count, bool)
141
+ else None
142
+ ),
143
+ os_set=(
144
+ tuple(v for v in os_set if isinstance(v, str)) if isinstance(os_set, list) else None
145
+ ),
146
+ declares=(
147
+ {k: v for k, v in declares.items() if isinstance(k, str) and isinstance(v, bool)}
148
+ if isinstance(declares, dict)
149
+ else None
150
+ ),
151
+ )