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,295 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """A comment-preserving, text-level append to `board.yaml`'s `models:` list
3
+ (tan-cli#1286, `tan model add`).
4
+
5
+ Customers hand-write and annotate `board.yaml`; a `yaml.safe_load` /
6
+ `yaml.dump` round-trip deletes every comment. Rather than take a new
7
+ dependency (`ruamel.yaml`), this splices TEXT: it finds the one top-level
8
+ `models:` list with a small line-oriented scan, inserts the new entry's lines
9
+ right after that list's last content line, and returns every other byte of the
10
+ file untouched -- the same targeted-splice shape `tan.core.scaffold.
11
+ splice_companion_cores` uses for `cores:`.
12
+
13
+ **It refuses rather than guesses.** `BoardEditRefused` is raised for every
14
+ shape the scan cannot place an insertion in with certainty: a flow-style
15
+ `models: [...]`/`{...}` (only the empty `[]` is rewritten), an anchor/alias/tag
16
+ or scalar on the `models:` line, a quoted `models` key, a second top-level
17
+ `models:`, a multi-document file, a tab in the list's indentation, or a list
18
+ whose lines do not read as a block sequence. A final guard re-parses the old
19
+ and the new text with `yaml.safe_load` and refuses unless the new document is
20
+ EXACTLY the old one plus the one appended entry -- so a shape the scan
21
+ misjudged cannot reach disk as a silently different document.
22
+
23
+ The only bytes that ever change: the inserted lines, the `[]` of an empty
24
+ flow list, and a missing final newline on the line the insertion follows.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import copy
30
+ import re
31
+ from collections.abc import Mapping
32
+ from typing import Any
33
+
34
+ _LINE_RE = re.compile(r"[^\n]*\n|[^\n]+")
35
+ _MODELS_KEY_RE = re.compile(r"^models[ ]*:(?P<rest>([ \t].*)?)$")
36
+ _QUOTED_MODELS_RE = re.compile(r"""^["']models["']\s*:""")
37
+ _DOC_START_RE = re.compile(r"^---(\s|$)")
38
+ _DOC_END_RE = re.compile(r"^\.\.\.(\s|$)")
39
+ _COMPLEX_KEY_RE = re.compile(r"^\?(\s|$)")
40
+ _EMPTY_VALUES = ("[]", "~", "null", "Null", "NULL")
41
+ _BOM = "\ufeff"
42
+
43
+ #: Indent for a `models:` list this module creates itself.
44
+ DEFAULT_ITEM_INDENT = 2
45
+
46
+
47
+ class BoardEditRefused(Exception):
48
+ """The `models:` list cannot be appended to safely; `str(err)` says why."""
49
+
50
+
51
+ def _split_lines(text: str) -> list[str]:
52
+ # Not `str.splitlines`: it also breaks on form feed, NEL and U+2028, none
53
+ # of which are YAML line breaks.
54
+ return _LINE_RE.findall(text)
55
+
56
+
57
+ def _content(line: str) -> str:
58
+ return line.rstrip("\r\n")
59
+
60
+
61
+ def _text(lines: list[str], n: int) -> str:
62
+ """Line `n` without its line ending, and (line 0 only) without a leading
63
+ BOM -- YAML ignores a BOM, so a column-0 key behind one is still column 0."""
64
+ line = _content(lines[n]).rstrip("\r")
65
+ return line[1:] if n == 0 and line.startswith(_BOM) else line
66
+
67
+
68
+ def _strip_comment(rest: str) -> tuple[str, str]:
69
+ """`(value, comment)` of the text after a `key:` -- `comment` keeps its
70
+ leading `#` (and the whitespace before it is dropped from `value`)."""
71
+ match = re.search(r"(^|\s)#", rest)
72
+ if match is None:
73
+ return rest.strip(), ""
74
+ return rest[: match.start()].strip(), rest[match.start() :].strip()
75
+
76
+
77
+ def _is_seq_item(stripped: str) -> bool:
78
+ return stripped == "-" or stripped.startswith("- ")
79
+
80
+
81
+ def _scalar(value: Any) -> str:
82
+ import yaml # noqa: PLC0415 (declared dependency)
83
+
84
+ if isinstance(value, bool):
85
+ return "true" if value else "false"
86
+ if isinstance(value, int):
87
+ return str(value)
88
+ if isinstance(value, str):
89
+ out = yaml.safe_dump(value, default_flow_style=True, width=2**30, allow_unicode=True)
90
+ out = out.rstrip("\n")
91
+ if out.endswith("\n..."):
92
+ out = out[:-4]
93
+ if "\n" in out:
94
+ raise BoardEditRefused(f"cannot render {value!r} as a single-line YAML scalar")
95
+ return out
96
+ raise BoardEditRefused(f"cannot render a {type(value).__name__} value in a `models:` entry")
97
+
98
+
99
+ def _render_mapping(mapping: Mapping, indent: int, first_prefix: str | None) -> list[str]:
100
+ """Block-style lines for `mapping`; `first_prefix` (`"- "`) replaces the
101
+ first line's leading indent for a sequence item."""
102
+ lines: list[str] = []
103
+ pad = " " * indent
104
+ for n, (key, value) in enumerate(mapping.items()):
105
+ lead = first_prefix if (n == 0 and first_prefix is not None) else pad
106
+ k = _scalar(key)
107
+ if isinstance(value, Mapping):
108
+ if not value:
109
+ raise BoardEditRefused(f"cannot render empty mapping under `{key}`")
110
+ lines.append(f"{lead}{k}:")
111
+ lines.extend(_render_mapping(value, indent + 2, None))
112
+ elif isinstance(value, (list, tuple)):
113
+ if not all(isinstance(v, (int, str)) and not isinstance(v, bool) for v in value):
114
+ raise BoardEditRefused(f"cannot render the list under `{key}`")
115
+ lines.append(f"{lead}{k}: [{', '.join(_scalar(v) for v in value)}]")
116
+ else:
117
+ lines.append(f"{lead}{k}: {_scalar(value)}")
118
+ return lines
119
+
120
+
121
+ def render_entry(entry: Mapping, item_indent: int = DEFAULT_ITEM_INDENT) -> list[str]:
122
+ """The block-sequence lines (no line endings) for one `models:` entry."""
123
+ return _render_mapping(entry, item_indent + 2, " " * item_indent + "- ")
124
+
125
+
126
+ def _scan(lines: list[str]) -> tuple[int | None, int | None]:
127
+ """`(models key line | None, line of a trailing `...` | None)`. Refuses a
128
+ complex `? key`, a quoted `models` key, a duplicate key and a second YAML
129
+ document (a second `---`, or content after `...`); a leading `%` directive
130
+ plus one `---`, and a trailing `...`, are fine."""
131
+ seen_content = False
132
+ seen_start = False
133
+ end_marker: int | None = None
134
+ found: list[int] = []
135
+ for n in range(len(lines)):
136
+ line = _text(lines, n)
137
+ if line[:1] in (" ", "\t", "#", ""):
138
+ continue
139
+ if end_marker is not None:
140
+ raise BoardEditRefused("board.yaml has content after a `...` document end")
141
+ if line[0] == "%" and not seen_content and not seen_start:
142
+ continue
143
+ if _DOC_START_RE.match(line):
144
+ if seen_content or seen_start:
145
+ raise BoardEditRefused("board.yaml has more than one YAML document")
146
+ seen_start = True
147
+ continue
148
+ if _DOC_END_RE.match(line):
149
+ end_marker = n
150
+ continue
151
+ seen_content = True
152
+ if _COMPLEX_KEY_RE.match(line):
153
+ raise BoardEditRefused("board.yaml uses a complex `? key`")
154
+ if _QUOTED_MODELS_RE.match(line):
155
+ raise BoardEditRefused("board.yaml quotes its `models` key")
156
+ if _MODELS_KEY_RE.match(line):
157
+ found.append(n)
158
+ if len(found) > 1:
159
+ raise BoardEditRefused("board.yaml has more than one top-level `models:` key")
160
+ return (found[0] if found else None), end_marker
161
+
162
+
163
+ def _list_extent(lines: list[str], key_n: int) -> tuple[int, int | None]:
164
+ """`(last content line, item indent)` of the block list under line
165
+ `key_n`; `(key_n, None)` for an empty one. Trailing blank/comment lines
166
+ are not part of the list, so the insertion lands before them."""
167
+ last = key_n
168
+ item_indent: int | None = None
169
+ for n in range(key_n + 1, len(lines)):
170
+ line = _text(lines, n)
171
+ stripped = line.lstrip(" ")
172
+ if stripped == "" or stripped.startswith("#"):
173
+ continue
174
+ ind = len(line) - len(stripped)
175
+ if stripped[:1] == "\t" or "\t" in line[:ind]:
176
+ raise BoardEditRefused("`models:` list is indented with a tab")
177
+ if ind == 0 and not _is_seq_item(stripped):
178
+ break
179
+ if item_indent is None:
180
+ if not _is_seq_item(stripped):
181
+ raise BoardEditRefused("`models:` is not a block list")
182
+ item_indent = ind
183
+ elif ind < item_indent or (ind == item_indent and not _is_seq_item(stripped)):
184
+ raise BoardEditRefused("`models:` list has inconsistent indentation")
185
+ last = n
186
+ return last, item_indent
187
+
188
+
189
+ def append_models_entry(text: str, entry: Mapping) -> str:
190
+ """`text` (a `board.yaml`) with `entry` appended to its `models:` list,
191
+ created at the end of the file when absent. Raises `BoardEditRefused`."""
192
+ old_doc = _strict_load(text, "board.yaml")
193
+ if old_doc is None:
194
+ old_doc = {}
195
+ if not isinstance(old_doc, dict):
196
+ raise BoardEditRefused("board.yaml is not a YAML mapping")
197
+
198
+ lines = _split_lines(text)
199
+ eol = "\r\n" if "\r\n" in text else "\n"
200
+ key_n, end_n = _scan(lines)
201
+ new_lines = list(lines)
202
+ if key_n is None:
203
+ at = len(new_lines) if end_n is None else end_n
204
+ if at and not new_lines[at - 1].endswith("\n"):
205
+ new_lines[at - 1] += eol
206
+ block = ["models:", *render_entry(entry, DEFAULT_ITEM_INDENT)]
207
+ new_lines[at:at] = [ln + eol for ln in block]
208
+ else:
209
+ key_raw = lines[key_n]
210
+ rest = _MODELS_KEY_RE.match(_text(lines, key_n)).group("rest") # type: ignore[union-attr]
211
+ value, comment = _strip_comment(rest)
212
+ if value in _EMPTY_VALUES:
213
+ new_key = ("\ufeff" if key_n == 0 and key_raw.startswith(_BOM) else "") + "models:"
214
+ new_lines[key_n] = (
215
+ new_key + (f" {comment}" if comment else "") + key_raw[len(_content(key_raw)) :]
216
+ )
217
+ elif value:
218
+ raise BoardEditRefused(f"`models:` is written as `{value}`, not a block list")
219
+ last, item_indent = _list_extent(lines, key_n)
220
+ if value in _EMPTY_VALUES and item_indent is not None:
221
+ raise BoardEditRefused(f"`models: {value}` is followed by list items")
222
+ if not new_lines[last].endswith("\n"):
223
+ new_lines[last] += eol
224
+ indent = DEFAULT_ITEM_INDENT if item_indent is None else item_indent
225
+ new_lines[last + 1 : last + 1] = [ln + eol for ln in render_entry(entry, indent)]
226
+
227
+ new_text = "".join(new_lines)
228
+ _verify(old_doc, new_text, entry)
229
+ return new_text
230
+
231
+
232
+ def _strict_load(text: str, what: str) -> Any:
233
+ """`yaml.safe_load` that also rejects a repeated mapping key -- the check
234
+ `tan build`'s own strict loader applies, which plain `safe_load` (last
235
+ key wins) hides. Refuses on any failure, recursion included."""
236
+ import yaml # noqa: PLC0415 (declared dependency)
237
+
238
+ class Strict(yaml.SafeLoader):
239
+ pass
240
+
241
+ def construct(loader: Any, node: Any) -> dict:
242
+ seen: set = set()
243
+ for key_node, _ in node.value:
244
+ if key_node.tag == "tag:yaml.org,2002:merge":
245
+ continue
246
+ key = loader.construct_object(key_node, deep=True)
247
+ if key in seen:
248
+ raise BoardEditRefused(f"{what} repeats the mapping key {key!r}")
249
+ seen.add(key)
250
+ loader.flatten_mapping(node)
251
+ return {
252
+ loader.construct_object(k, deep=True): loader.construct_object(v, deep=True)
253
+ for k, v in node.value
254
+ }
255
+
256
+ Strict.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, construct)
257
+ try:
258
+ return yaml.load(text, Loader=Strict) # noqa: S506 (SafeLoader subclass)
259
+ except BoardEditRefused:
260
+ raise
261
+ except (yaml.YAMLError, RecursionError, TypeError) as err:
262
+ raise BoardEditRefused(f"{what} does not parse cleanly: {err}") from err
263
+
264
+
265
+ def _same(a: Any, b: Any) -> bool:
266
+ """Structural equality that treats NaN as equal to NaN (`.nan` in a board
267
+ would otherwise make every edit look like a corruption)."""
268
+ if isinstance(a, float) and isinstance(b, float) and a != a and b != b:
269
+ return True
270
+ if isinstance(a, dict) and isinstance(b, dict):
271
+ return a.keys() == b.keys() and all(_same(a[k], b[k]) for k in a)
272
+ if isinstance(a, list) and isinstance(b, list):
273
+ return len(a) == len(b) and all(_same(x, y) for x, y in zip(a, b))
274
+ return type(a) is type(b) and a == b
275
+
276
+
277
+ def _verify(old_doc: dict, new_text: str, entry: Mapping) -> None:
278
+ import yaml # noqa: PLC0415 (declared dependency)
279
+
280
+ new_doc = _strict_load(new_text, "the edited board.yaml")
281
+ expected = copy.deepcopy(old_doc)
282
+ existing = expected.get("models")
283
+ if existing is None:
284
+ existing = []
285
+ if not isinstance(existing, list):
286
+ raise BoardEditRefused("`models:` is not a list")
287
+ expected["models"] = [*existing, yaml.safe_load(yaml.safe_dump(dict(entry)))]
288
+ try:
289
+ same = _same(new_doc, expected)
290
+ except RecursionError:
291
+ same = False
292
+ if not same:
293
+ raise BoardEditRefused(
294
+ "the edit would change more than the appended entry (unusual `models:` layout)"
295
+ )
@@ -0,0 +1,118 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Position-aware YAML loader for board.yaml diagnostics.
3
+
4
+ PORTED from alp-sdk `scripts/alp_cli/yaml_pos.py` (pinned by
5
+ `tests/gates/test_planner_relocation_freshness.py::HAND_PORT_HASHES`).
6
+ The body is unchanged; only this docstring is tan's.
7
+
8
+ Subclasses PyYAML's SafeLoader so every constructed mapping and
9
+ sequence carries ``__line__``, ``__column__``, ``__end_line__``,
10
+ ``__end_column__`` (1-based) and a per-key ``__keys__`` table that
11
+ maps key name -> (line, col, value_line, value_col).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from pathlib import Path
17
+ from typing import Any, Literal
18
+
19
+ import yaml
20
+
21
+
22
+ class _PosLoader(yaml.SafeLoader):
23
+ pass
24
+
25
+
26
+ def _construct_mapping(loader: _PosLoader, node: yaml.MappingNode) -> dict[str, Any]:
27
+ # Detect duplicates against the node's OWN keys, BEFORE flatten_mapping().
28
+ #
29
+ # flatten_mapping() splices the merged mapping's keys into node.value, so
30
+ # a spec-legal override of a merged key --
31
+ #
32
+ # base: &b {a: 1}
33
+ # derived: {<<: *b, a: 9}
34
+ #
35
+ # -- would appear as `a` twice afterwards and be rejected. Overriding a
36
+ # merged key is the entire point of the merge construct (YAML 1.1 merge:
37
+ # the explicit key wins), and `dev` accepts it, so rejecting it here would
38
+ # refuse customer board.yaml files that build fine today.
39
+ seen: set[Any] = set()
40
+ for key_node, _value_node in node.value:
41
+ if key_node.tag == "tag:yaml.org,2002:merge":
42
+ continue
43
+ key = loader.construct_object(key_node, deep=True)
44
+ if key in seen:
45
+ mark = key_node.start_mark
46
+ raise ValueError(
47
+ f"duplicate key {key!r} at line {mark.line + 1}, "
48
+ f"column {mark.column + 1}"
49
+ )
50
+ seen.add(key)
51
+
52
+ loader.flatten_mapping(node)
53
+ mapping: dict[str, Any] = {}
54
+ keys: dict[str, dict[str, int]] = {}
55
+ for key_node, value_node in node.value:
56
+ key = loader.construct_object(key_node, deep=True)
57
+ # Last-wins is correct AFTER flattening: PyYAML prepends the merged
58
+ # keys, so an explicit key encountered later overrides the merged one.
59
+ value = loader.construct_object(value_node, deep=True)
60
+ mapping[key] = value
61
+ keys[key] = {
62
+ "line": key_node.start_mark.line + 1,
63
+ "col": key_node.start_mark.column + 1,
64
+ "value_line": value_node.start_mark.line + 1,
65
+ "value_col": value_node.start_mark.column + 1,
66
+ "value_end_line": value_node.end_mark.line + 1,
67
+ "value_end_col": value_node.end_mark.column + 1,
68
+ }
69
+ mapping["__line__"] = node.start_mark.line + 1
70
+ mapping["__column__"] = node.start_mark.column + 1
71
+ mapping["__end_line__"] = node.end_mark.line + 1
72
+ mapping["__end_column__"] = node.end_mark.column + 1
73
+ mapping["__keys__"] = keys
74
+ return mapping
75
+
76
+
77
+ def _construct_sequence(loader: _PosLoader, node: yaml.SequenceNode) -> list[Any]:
78
+ items: list[Any] = []
79
+ for child in node.value:
80
+ item = loader.construct_object(child, deep=True)
81
+ if isinstance(item, dict):
82
+ item.setdefault("__line__", child.start_mark.line + 1)
83
+ item.setdefault("__column__", child.start_mark.column + 1)
84
+ item.setdefault("__end_line__", child.end_mark.line + 1)
85
+ item.setdefault("__end_column__", child.end_mark.column + 1)
86
+ items.append(item)
87
+ return items
88
+
89
+
90
+ _PosLoader.add_constructor(
91
+ yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _construct_mapping
92
+ )
93
+ _PosLoader.add_constructor(
94
+ yaml.resolver.BaseResolver.DEFAULT_SEQUENCE_TAG, _construct_sequence
95
+ )
96
+
97
+
98
+ def load_with_positions(text: str, source: str | Path) -> dict[str, Any]:
99
+ """Parse a YAML document, attaching position metadata to mappings."""
100
+ data = yaml.load(text, Loader=_PosLoader)
101
+ if not isinstance(data, dict):
102
+ raise ValueError(f"{source}: top-level YAML is not a mapping")
103
+ return data
104
+
105
+
106
+ def node_position(
107
+ mapping: dict[str, Any],
108
+ key: str,
109
+ target: Literal["key", "value"] = "key",
110
+ ) -> tuple[int, int]:
111
+ """Return (line, column), 1-based, for a key or its value."""
112
+ table = mapping.get("__keys__")
113
+ if not table or key not in table:
114
+ return (mapping.get("__line__", 1), mapping.get("__column__", 1))
115
+ entry = table[key]
116
+ if target == "value":
117
+ return (entry["value_line"], entry["value_col"])
118
+ return (entry["line"], entry["col"])