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,164 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """`tan bootstrap`'s `zephyr/patches.yml` phase (tan-cli#1296).
3
+
4
+ alp-sdk carries patches against Zephyr and its modules in `zephyr/patches.yml`
5
+ (e.g. `ipm_arm_mhuv2.c`'s `poll_out`, hal_alif's `se_service_boot_cpu`).
6
+ `scripts/bootstrap.sh` applies them and documents them as required to build;
7
+ `tan bootstrap` did not, and a tan-made workspace failed to compile the
8
+ `peripheral-io/hello-world` example for E1M-AEN801 (m55_hp slice) with
9
+ `'struct ipm_driver_api' has no member named 'poll_out'`. This mirrors that
10
+ script's algorithm -- the SDK's own `scripts/verify_west_patches.py` is the
11
+ oracle for "is it applied"; tan only drives it (it must not re-implement the
12
+ SDK's patch knowledge).
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from pathlib import Path
17
+ from typing import TYPE_CHECKING
18
+
19
+ from tan.core.west_patches import classify_verify, output_tail, parse_unapplied
20
+
21
+ if TYPE_CHECKING: # bootstrap_cmd imports THIS module, so no runtime import back
22
+ from tan.commands.bootstrap_cmd import Log, Runner, VenvBin, Workspace
23
+
24
+ #: Issue-code suffixes (`bootstrap.<suffix>`); `FAILED` is a `WORKSPACE_BLOCKING`
25
+ #: member: a tree that lacks the patches cannot build what needs them.
26
+ FAILED = "west-patches-failed"
27
+ UNCHECKED = "west-patches-unchecked"
28
+
29
+ _VERIFIER = Path("scripts") / "verify_west_patches.py"
30
+ _PATCHES_YML = Path("zephyr") / "patches.yml"
31
+
32
+ _OPT_OUT = (
33
+ " Pass `--no-patches` to skip this step (zephyr/patches.yml is then NOT applied, "
34
+ "and builds that need those patches will not compile)."
35
+ )
36
+
37
+
38
+ def _detail(*chunks: str) -> str:
39
+ tail = output_tail("\n".join(chunk for chunk in chunks if chunk))
40
+ return f"\n{tail}" if tail else ""
41
+
42
+
43
+ def _fail(log: Log, message: str, *output: str) -> None:
44
+ log.warn(FAILED, f"{message}{_OPT_OUT}{_detail(*output)}")
45
+
46
+
47
+ def _report(
48
+ ws: Workspace, log: Log, verdict: str, out: str, err: str, *, after_apply: bool
49
+ ) -> bool:
50
+ """Act on a verifier verdict. True when the phase is finished; False only
51
+ for the one verdict that means "go apply" (`unapplied`, before applying)."""
52
+ if verdict == "applied":
53
+ when = "verified applied" if after_apply else "already applied"
54
+ log.line(f"zephyr/patches.yml {when} in {ws.workspace_dir}")
55
+ elif verdict == "unchecked":
56
+ # Everything inspectable is patched; a module the file names just is not
57
+ # in this workspace. Normal for a narrow one, so a warning.
58
+ log.warn(
59
+ UNCHECKED,
60
+ "some zephyr/patches.yml modules are not in this workspace, so their patches "
61
+ f"could not be checked.{_detail(out, err)}",
62
+ )
63
+ elif verdict == "unrunnable":
64
+ # Exit 2 / never launched: the patch state is UNKNOWN, so do not claim
65
+ # "not applied".
66
+ _fail(
67
+ log,
68
+ "could not run or inspect the zephyr/patches.yml verifier "
69
+ "(scripts/verify_west_patches.py) -- the patch state is unknown.",
70
+ out, err,
71
+ )
72
+ elif after_apply:
73
+ _fail(
74
+ log,
75
+ f"zephyr/patches.yml is still not applied in {ws.workspace_dir} after "
76
+ "`west patch apply`.",
77
+ out, err,
78
+ )
79
+ else:
80
+ return False
81
+ return True
82
+
83
+
84
+ def _apply_missing(
85
+ ws: Workspace, venv: VenvBin, log: Log, runner: Runner, modules: list[str]
86
+ ) -> bool:
87
+ """`west patch apply` per module. False (after recording the error) on the
88
+ first failure."""
89
+ # Late import: `bootstrap_cmd` imports this module, so a top-level import
90
+ # of its `_west_argv` would be circular.
91
+ from tan.commands.bootstrap_cmd import _west_argv
92
+
93
+ for module in modules:
94
+ # PER MODULE, never a bare `west patch apply`: it is not idempotent, so
95
+ # on a partially patched workspace (zephyr patched, mcuboot fresh) it
96
+ # re-applies the patched module's patches and dies on the first.
97
+ # `--dst-module` is a flag of `west patch` ITSELF, so it precedes
98
+ # `apply`; the other order is a usage error.
99
+ log.line(f"Applying zephyr/patches.yml for module '{module}'")
100
+ failure = runner.run(
101
+ _west_argv(venv, ["patch", "--dst-module", module, "apply"]), cwd=ws.workspace_dir
102
+ )
103
+ if failure is not None:
104
+ # `clean` is the zephyr `west patch` default pair `git checkout .`
105
+ # + `git clean -d -f -x` run in the module: it is the way out of a
106
+ # half-applied patch, and it discards uncommitted work there.
107
+ _fail(
108
+ log,
109
+ f"`west patch --dst-module {module} apply` failed, so the module may be "
110
+ f"half-patched. To recover, run `west patch --dst-module {module} clean` "
111
+ f"from {ws.workspace_dir} (it runs `git checkout .` and `git clean -d -f "
112
+ f"-x` in that module, discarding uncommitted changes there), then re-run "
113
+ f"`tan bootstrap`.",
114
+ failure,
115
+ )
116
+ return False
117
+ return True
118
+
119
+
120
+ def patches_phase(
121
+ ws: Workspace, venv: VenvBin, log: Log, runner: Runner, sdk_root: str
122
+ ) -> None:
123
+ """Verify, then apply only what is missing, then verify again.
124
+
125
+ Non-fatal like `pip_phase`/`toolchain_phase`: a failure is a recorded
126
+ `bootstrap.west-patches-failed` (which blocks `complete.` unless
127
+ `--allow-partial`), never an abort, so it cannot skip the toolchain phase.
128
+ """
129
+ sdk = Path(sdk_root)
130
+ verifier = sdk / _VERIFIER
131
+ if not verifier.is_file() or not (sdk / _PATCHES_YML).is_file():
132
+ # An older SDK tag simply has no patch set to apply -- not an error.
133
+ log.line("Skipping zephyr/patches.yml: this alp-sdk checkout carries no patch verifier")
134
+ return
135
+
136
+ # Run from the SDK root, like bootstrap.sh, so the verifier resolves its
137
+ # own `patches.yml` the way it was written to.
138
+ verify = [
139
+ str(venv.python), str(verifier),
140
+ "--topdir", str(ws.workspace_dir), "--west", str(venv.west),
141
+ ]
142
+ code, out, err = runner.run_status(verify, cwd=sdk)
143
+ if runner.dry_run:
144
+ log.line("Would verify zephyr/patches.yml and apply any missing patches (--dry-run)")
145
+ return
146
+ if _report(ws, log, classify_verify(code), out, err, after_apply=False):
147
+ return
148
+
149
+ code, listing, list_err = runner.run_status([*verify, "--list-unapplied"], cwd=sdk)
150
+ modules = parse_unapplied(listing) if code == 0 else []
151
+ if not modules:
152
+ _fail(
153
+ log,
154
+ "zephyr/patches.yml is not applied but the verifier named no module to patch "
155
+ "-- run scripts/verify_west_patches.py directly to see why.",
156
+ out, err, list_err,
157
+ )
158
+ return
159
+
160
+ if _apply_missing(ws, venv, log, runner, modules):
161
+ # `west patch apply` exits 0 on three do-nothing paths, so its status is
162
+ # not evidence; the verifier is.
163
+ code, out, err = runner.run_status(verify, cwd=sdk)
164
+ _report(ws, log, classify_verify(code), out, err, after_apply=True)
@@ -0,0 +1,155 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """IO half of tan-cli#1496's toolchain-archive check: download the pinned
3
+ `arm-zephyr-eabi` archive ourselves, hash it against alp-sdk's pin while it
4
+ streams, and extract it into the layout the SDK's own `setup.sh` produces
5
+ (`<sdk>/gnu/arm-zephyr-eabi`). West verifies only the minimal bundle and its
6
+ `setup.sh` downloads this archive unchecked, so west is told
7
+ `--no-gnu-toolchains` and this module does the job (see `tan.core.toolchain_pin`)."""
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import os
12
+ import shutil
13
+ import subprocess
14
+ import tarfile
15
+ import tempfile
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+
19
+ from tan.core.proxy import select_https_proxy
20
+ from tan.core.subprocess_env import spawn_env
21
+ from tan.core.toolchain_provision import (
22
+ TMP_SUFFIX_PREFIX,
23
+ TOOLCHAIN_COMPONENT,
24
+ ToolchainArtifact,
25
+ )
26
+
27
+ #: The artifact row whose archive west's `setup.sh` would have fetched.
28
+ TOOLCHAIN_ARTIFACT_COMPONENT = f"{TOOLCHAIN_COMPONENT}-toolchain"
29
+ SEVEN_ZIP_EXTRACTORS = ("7z", "7za", "7zr", "7zz", "7zzs")
30
+ _CHUNK = 1 << 20
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class FetchOutcome:
35
+ #: "ok" | "mismatch" (hash differs from the pin) | "unverified" (could not download)
36
+ #: | "install" (downloaded and matched, but could not be extracted/placed).
37
+ kind: str
38
+ message: str = ""
39
+
40
+
41
+ def toolchain_artifact(artifacts: tuple[ToolchainArtifact, ...]) -> ToolchainArtifact | None:
42
+ found = [a for a in artifacts if a.component == TOOLCHAIN_ARTIFACT_COMPONENT]
43
+ return found[0] if len(found) == 1 else None
44
+
45
+
46
+ def _download(url: str, dest: Path, art: ToolchainArtifact) -> FetchOutcome:
47
+ from tan.commands.sdk_cmd import ( # noqa: PLC0415
48
+ NETWORK_TIMEOUT_SECONDS,
49
+ _releases_opener,
50
+ _unroutable_proxy_refusal,
51
+ )
52
+
53
+ proxy = select_https_proxy(url)
54
+ refusal = _unroutable_proxy_refusal(proxy, url)
55
+ if refusal is not None:
56
+ return FetchOutcome("unverified", refusal)
57
+ import urllib.request # noqa: PLC0415
58
+
59
+ request = urllib.request.Request(url, headers={"User-Agent": "tan-cli/0"}) # noqa: S310
60
+ digest = hashlib.sha256()
61
+ total = 0
62
+ try:
63
+ with _releases_opener(proxy).open(request, timeout=NETWORK_TIMEOUT_SECONDS) as resp:
64
+ with open(dest, "wb") as out:
65
+ while True:
66
+ chunk = resp.read(_CHUNK)
67
+ if not chunk:
68
+ break
69
+ total += len(chunk)
70
+ if total > art.size_bytes:
71
+ return FetchOutcome(
72
+ "mismatch",
73
+ f"{url} is larger than the {art.size_bytes} bytes alp-sdk pins "
74
+ f"for {art.filename}",
75
+ )
76
+ digest.update(chunk)
77
+ out.write(chunk)
78
+ except Exception as err: # noqa: BLE001 -- every transport failure is a message
79
+ return FetchOutcome("unverified", f"{type(err).__name__}: {err}")
80
+ if total < art.size_bytes:
81
+ return FetchOutcome(
82
+ "unverified",
83
+ f"incomplete download: {total} of the pinned {art.size_bytes} bytes of "
84
+ f"{art.filename} arrived before the stream ended",
85
+ )
86
+ got = digest.hexdigest()
87
+ if got != art.sha256.strip().lower():
88
+ return FetchOutcome(
89
+ "mismatch",
90
+ f"{art.filename} downloaded from {url} has sha256 {got}, but alp-sdk pins "
91
+ f"{art.sha256} (a proxy rewriting the download gives the same symptom)",
92
+ )
93
+ return FetchOutcome("ok")
94
+
95
+
96
+ def _find_seven_zip() -> str | None:
97
+ """Absolute path of the first 7-Zip on PATH that is not inside the current
98
+ directory. The bare name is never spawned: on Windows CreateProcess searches the
99
+ parent's cwd first, so a planted `7z.exe` in the project would run (cf. #1483)."""
100
+ from tan.core.tool_lookup import resolve_tool # noqa: PLC0415
101
+
102
+ cwd = Path(os.getcwd()).resolve()
103
+ for name in SEVEN_ZIP_EXTRACTORS:
104
+ found = resolve_tool(name, os.environ).resolved
105
+ if found is None or not Path(found).is_absolute():
106
+ continue
107
+ real = Path(found).resolve()
108
+ if real == cwd or cwd in real.parents:
109
+ continue
110
+ return str(real)
111
+ return None
112
+
113
+
114
+ def _extract(archive: Path, dest: Path) -> str | None:
115
+ """Error text, or None. `.tar.xz` via tarfile's `data` filter (no absolute paths,
116
+ no escapes, no device nodes); `.7z` via a 7-Zip binary (Windows' archive format)."""
117
+ dest.mkdir(parents=True, exist_ok=True)
118
+ try:
119
+ if archive.name.endswith(".7z"):
120
+ program = _find_seven_zip()
121
+ if program is None:
122
+ return "no usable 7-Zip on PATH to extract the .7z toolchain archive"
123
+ proc = subprocess.run( # noqa: S603
124
+ [program, "x", "-y", f"-o{dest}", "--", str(archive)],
125
+ capture_output=True, text=True, check=False, env=spawn_env(),
126
+ )
127
+ return None if proc.returncode == 0 else f"{program} exited {proc.returncode}"
128
+ with tarfile.open(archive, "r:*") as tar:
129
+ tar.extractall(dest, filter="data")
130
+ except (OSError, tarfile.TarError, subprocess.SubprocessError) as err:
131
+ return f"{type(err).__name__}: {err}"
132
+ return None
133
+
134
+
135
+ def install_pinned_toolchain(
136
+ base_url: str, art: ToolchainArtifact, tmp_dir: Path, scratch_parent: Path, leaf: str
137
+ ) -> FetchOutcome:
138
+ """Download + hash + extract `art` into `<tmp_dir>/gnu/` (the layout `setup.sh`
139
+ gives). The scratch dir is a swept `<leaf>.tmp-*` sibling, deleted on every path."""
140
+ url = base_url.rstrip("/") + "/" + art.filename
141
+ with tempfile.TemporaryDirectory(
142
+ dir=scratch_parent, prefix=f"{leaf}{TMP_SUFFIX_PREFIX}dl-"
143
+ ) as scratch:
144
+ archive = Path(scratch) / art.filename
145
+ got = _download(url, archive, art)
146
+ if got.kind != "ok":
147
+ return got
148
+ err = _extract(archive, tmp_dir / "gnu")
149
+ if err is not None:
150
+ return FetchOutcome("install", f"extracting {art.filename} failed: {err}")
151
+ if not (tmp_dir / "gnu" / TOOLCHAIN_COMPONENT).is_dir():
152
+ return FetchOutcome(
153
+ "install", f"{art.filename} did not extract a gnu/{TOOLCHAIN_COMPONENT} directory"
154
+ )
155
+ return FetchOutcome("ok")
@@ -0,0 +1,36 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """IO half of tan-cli#1496: fetch the release `sha256.sum` through the same
3
+ proxy decision + OS-trust-store TLS context `tan sdk list --online` uses, then
4
+ hand the text to `tan.core.toolchain_pin`. Only this small file is downloaded."""
5
+ from __future__ import annotations
6
+
7
+ from tan.core.proxy import select_https_proxy
8
+
9
+ #: Cap on the sum file. The real one is ~100 KiB; anything bigger is not it.
10
+ MAX_SUM_BYTES = 4 * 1024 * 1024
11
+
12
+
13
+ def fetch_sum_text(url: str) -> tuple[str | None, str | None]:
14
+ """`(text, None)` or `(None, why)`. Never raises: every transport failure is a
15
+ message, so the caller always refuses visibly instead of passing silently."""
16
+ from tan.commands.sdk_cmd import ( # noqa: PLC0415
17
+ NETWORK_TIMEOUT_SECONDS,
18
+ _releases_opener,
19
+ _unroutable_proxy_refusal,
20
+ )
21
+
22
+ proxy = select_https_proxy(url)
23
+ refusal = _unroutable_proxy_refusal(proxy, url)
24
+ if refusal is not None:
25
+ return None, refusal
26
+ import urllib.request # noqa: PLC0415
27
+
28
+ request = urllib.request.Request(url, headers={"User-Agent": "tan-cli/0"}) # noqa: S310
29
+ try:
30
+ with _releases_opener(proxy).open(request, timeout=NETWORK_TIMEOUT_SECONDS) as resp:
31
+ raw = resp.read(MAX_SUM_BYTES + 1)
32
+ except Exception as err: # noqa: BLE001 -- see docstring
33
+ return None, f"{type(err).__name__}: {err}"
34
+ if len(raw) > MAX_SUM_BYTES:
35
+ return None, f"response exceeds {MAX_SUM_BYTES} bytes"
36
+ return raw.decode("utf-8", errors="replace"), None
@@ -0,0 +1 @@
1
+ # SPDX-License-Identifier: Apache-2.0
@@ -0,0 +1,277 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """tan-cli#655: the file-existence side of Zephyr's own devicetree-overlay /
3
+ Kconfig-fragment auto-discovery, tracked across `tan build` invocations so
4
+ `execute.py` can tell when the discoverable SET changed (a new `app.overlay`
5
+ appeared, an existing one was removed, a new `boards/<board>.conf` showed up)
6
+ -- the one case Zephyr's own `CMAKE_CONFIGURE_DEPENDS` machinery does not
7
+ self-heal, because `configuration_files.cmake` caches its auto-discovery
8
+ result the first time it runs and only re-discovers `if(NOT DEFINED ...)`.
9
+ See `execute._maybe_reset_stale_configure_cache` for the fix this supports
10
+ and the measurement behind it.
11
+
12
+ Existence-only, not content: an EDIT to an ALREADY-discovered file is already
13
+ handled by Zephyr's own `CMAKE_CONFIGURE_DEPENDS` (the tracked file's mtime
14
+ bump alone triggers a reconfigure) -- this module exists only for the set-
15
+ membership change that mechanism cannot see."""
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ from pathlib import Path, PurePath
20
+
21
+ from tan.core.shapes import is_file, matches_glob_suffix
22
+
23
+ __all__ = [
24
+ "configure_inputs_stamp_path",
25
+ "discover_configure_inputs",
26
+ "read_configure_inputs_stamp",
27
+ "relative_key",
28
+ "resolve_zephyr_discovery_dir",
29
+ "write_configure_inputs_stamp",
30
+ ]
31
+
32
+ #: Lives inside west's own nested build dir (mirrors `manifest.py`'s
33
+ #: `_SDK_STAMP_FILE`/`sdk_stamp_path`), so a pristine wipe of that dir retires
34
+ #: this stamp atomically along with the CMake cache it describes -- it can
35
+ #: never outlive or misdescribe a build dir that no longer exists.
36
+ _CONFIGURE_INPUTS_STAMP_FILE = ".tan-configure-inputs"
37
+
38
+
39
+ def resolve_zephyr_discovery_dir(app_dir: str, build_root: Path) -> Path:
40
+ """Anchor a slice's plan-supplied `appDir` on `build_root`, then apply
41
+ Zephyr's own `app: ./src`-vs-self-contained convention, so a caller
42
+ working from the CONFIGURED app dir (`appDir` -- see
43
+ `tan.commands.build_cmd`'s "Note `appDir` is NOT the substituted value"
44
+ docstring) lands on the directory Zephyr actually auto-discovers
45
+ overlays/Kconfig fragments from -- the same one `west build` is pointed
46
+ at (tan-cli#798).
47
+
48
+ Anchoring mirrors both existing `appDir` probes,
49
+ `build_cmd._missing_app_dirs` and `build_cmd._substituted_app_dirs`: a
50
+ relative `appDir` resolves against `build_root`, never the `tan`
51
+ process's own CWD, so the result does not depend on where `tan` was
52
+ invoked from. The fallback mirrors `tan.planner.orchestrator`'s
53
+ `_zephyr_app_dir` (hash-audited, tan-cli#655's relocation gate -- not
54
+ importable here without reproducing its `_resolve_app_path` machinery
55
+ for a value, `appDir`, that is already resolved): when `app_dir` itself
56
+ carries no `CMakeLists.txt` but its parent does, Zephyr auto-discovers
57
+ from the parent instead (the shipped `app: ./src` sources-only
58
+ convention -- 96 of 105 alp-sdk example core entries use it). Neither
59
+ directory existing is reported as-is; the caller's own existence checks
60
+ (`discover_configure_inputs` returns the empty set for a missing dir)
61
+ already handle that case correctly without this function raising.
62
+
63
+ Unlike `build_cmd._substituted_app_dirs`, this does NOT carry an
64
+ `app_dir_path.is_dir()` precondition before falling back to the parent --
65
+ a nonexistent `app_dir` whose parent happens to carry a `CMakeLists.txt`
66
+ globs the parent here. Deliberately left unguarded: this function's only
67
+ caller runs on slices that already survived `_missing_app_dirs`
68
+ (`build.app-dir-missing` already failed a nonexistent `appDir` before
69
+ `execute_slices` dispatches it), so the divergence is unreachable in
70
+ practice, not merely tolerated."""
71
+ app_dir_path = Path(app_dir)
72
+ if not app_dir_path.is_absolute():
73
+ app_dir_path = build_root / app_dir_path
74
+ if (app_dir_path / "CMakeLists.txt").is_file():
75
+ return app_dir_path
76
+ if (app_dir_path.parent / "CMakeLists.txt").is_file():
77
+ return app_dir_path.parent
78
+ return app_dir_path
79
+
80
+
81
+ #: The two auto-discovery families `cmake/modules/configuration_files.cmake`
82
+ #: resolves `if(NOT DEFINED ...)`: Kconfig fragments (`CONF_FILE`, `NAMES
83
+ #: "prj.conf"` at the app root plus `boards/`/`socs/` qualifiers) and
84
+ #: devicetree overlays (`DTC_OVERLAY_FILE`, `NAMES "app.overlay"` plus the
85
+ #: same `boards/`/`socs/` qualifiers). Not an attempt to reproduce Zephyr's
86
+ #: exact per-qualifier candidate-name algorithm (board revision, `SUFFIX`,
87
+ #: sysbuild image scoping -- all of which drift across Zephyr versions);
88
+ #: tracking every `*.conf`/`*.overlay` at these locations is a superset that
89
+ #: stays correct even when it over-tracks a file Zephyr itself would not have
90
+ #: picked for THIS exact board target -- an extra reset is harmless, a missed
91
+ #: one is the bug this module exists to close.
92
+ _CANDIDATE_SUFFIXES = (".conf", ".overlay")
93
+
94
+ #: The two qualifier subtrees, walked to ANY depth below themselves -- the
95
+ #: `boards/**/*.conf` half of what this module used to express as six
96
+ #: `Path.glob` patterns. `**` matched zero or more directories, so a file
97
+ #: directly in `boards/` counted too; the walk below reproduces that by
98
+ #: starting at the subtree root itself, not at its children.
99
+ _CANDIDATE_SUBTREES = ("boards", "socs")
100
+
101
+ # WHY `os.scandir` AND NOT `Path.glob` (tan-cli#1132, following tan-cli#1127's
102
+ # identical swap in `new_som_cmd._known_board_names`).
103
+ #
104
+ # This module used to iterate `app_dir.glob(pattern)` OUTSIDE the `try` that
105
+ # was supposed to guard it:
106
+ #
107
+ # try:
108
+ # matches = app_dir.glob(pattern) # lazy: builds a generator
109
+ # except OSError:
110
+ # continue
111
+ # for path in matches: # the filesystem work happens HERE
112
+ #
113
+ # `Path.glob` is lazy, so the guarded statement could not fail and the
114
+ # `except OSError` was dead on every interpreter -- it wrapped the
115
+ # construction of a generator. Forcing iteration inside the `try` would have
116
+ # fixed the escape but not the contract, because `Path.glob` does not raise
117
+ # uniformly. Measured on this tree, non-root, for the two denied shapes
118
+ # (`app_dir` itself `chmod 000`, and `app_dir`'s PARENT `chmod 000`):
119
+ #
120
+ # primitive self-denied ancestor-denied
121
+ # Path.is_dir() True / True / True RAISE / RAISE / False
122
+ # list(Path.glob("*.conf")) [] / [] / [] RAISE / [] / []
123
+ # os.listdir() / os.scandir() RAISE / RAISE / RAISE RAISE / RAISE / RAISE
124
+ # (3.12.3 / 3.13.15 / 3.14.7)
125
+ #
126
+ # So `Path.glob` never raises at all for a self-denied directory, and raises
127
+ # for a denied ancestor only on 3.12.3 -- an `except OSError` around it is
128
+ # dead code on 3.13.15 and 3.14.7. `os.scandir` raises `PermissionError` for
129
+ # both shapes on all three, which is what makes the single `except OSError`
130
+ # in `discover_configure_inputs` load-bearing everywhere rather than on one
131
+ # interpreter by accident. The `if not app_dir.is_dir()` pre-flight is gone
132
+ # for the same reason (tan-cli#1127, and PR #1110/#1121 before it): it
133
+ # raised `PermissionError` on 3.12.3/3.13.15 for a denied ancestor and
134
+ # answered `False` on 3.14.7, so it could not be the thing that decides this
135
+ # function's contract. The real exception classifies it now.
136
+
137
+
138
+ def relative_key(path: PurePath, base: PurePath) -> str:
139
+ """@path relative to @base, spelled with forward slashes -- THE stamp
140
+ file's key, and the one expression `_candidate_files` uses to build one.
141
+
142
+ A separate function so the Windows spelling can be DRIVEN rather than
143
+ restated (tan-cli#1132 review): `os.scandir` hands back a
144
+ backslash-separated `entry.path` on Windows, so the key must be proven to
145
+ normalise. Taking `PurePath` (not `Path`) is what makes that provable on
146
+ ANY host without touching a filesystem, which is the point -- CI does run
147
+ this suite on `windows-latest` (`parity.yml`'s `python-tests-shard`, whose
148
+ matrix `os:` carries all three platforms; tan-cli#1153 corrects the "no
149
+ Windows host" claim that stood here), but only after a push, whereas a
150
+ pure-string oracle answers while the code is being written. A test passes
151
+ real `PureWindowsPath` values through THIS function, so a future edit here
152
+ (say, to `os.path.relpath`, which would leave backslashes in) reds the
153
+ oracle instead of sailing past a restatement of the old expression. The
154
+ keys are compared across runs, so a changed spelling would report every
155
+ tracked file as new on the first build after an upgrade."""
156
+ return path.relative_to(base).as_posix()
157
+
158
+
159
+ def _candidate_files(directory: Path, base: Path, *, recurse: bool) -> set[str]:
160
+ """Relative-POSIX paths (to @base) of the regular files directly in
161
+ @directory -- and, when @recurse, at any depth below it -- whose name
162
+ matches `_CANDIDATE_SUFFIXES`.
163
+
164
+ Raises `OSError` (`PermissionError`, `FileNotFoundError`,
165
+ `NotADirectoryError`, ...) for any directory it cannot list; the single
166
+ caller classifies. See the block comment above for why that is
167
+ `os.scandir` and not `Path.glob`.
168
+
169
+ Three `Path.glob` behaviours this deliberately reproduces rather than
170
+ quietly improving on:
171
+
172
+ * A symlinked directory is NOT descended into -- `**` does not follow
173
+ symlinks on any supported interpreter (3.13's own
174
+ `recurse_symlinks=False` default), which is what
175
+ `entry.is_dir(follow_symlinks=False)` preserves.
176
+ * A name matching a candidate suffix that is a DIRECTORY is not a match,
177
+ the old code's `path.is_file()` filter.
178
+ * A per-entry stat failure SKIPS that entry rather than failing the
179
+ walk. That is why the file test is `shapes.is_file` and not
180
+ `entry.is_file()`: measured on 3.12.3, 3.13.15 and 3.14.7 alike, a
181
+ self-referential `loop.conf -> loop.conf` symlink makes
182
+ `os.DirEntry.is_file()` raise `OSError(ELOOP)` where `Path.is_file()`
183
+ -- what the `glob` version called -- returns `False` and keeps the
184
+ rest of the set. Only the DIRECTORY LISTING is allowed to fail the
185
+ walk, and `os.scandir` above is the call that does it.
186
+
187
+ Symlinks are otherwise followed for the file test, so a symlink TO a
188
+ fragment still counts, exactly as it did through `glob` + `is_file()`."""
189
+ found: set[str] = set()
190
+ pending = [directory]
191
+ while pending:
192
+ with os.scandir(pending.pop()) as entries:
193
+ for entry in entries:
194
+ if recurse and entry.is_dir(follow_symlinks=False):
195
+ pending.append(Path(entry.path))
196
+ elif matches_glob_suffix(entry.name, *_CANDIDATE_SUFFIXES) and is_file(entry.path):
197
+ found.add(relative_key(Path(entry.path), base))
198
+ return found
199
+
200
+
201
+ def discover_configure_inputs(app_dir: Path) -> frozenset[str]:
202
+ """The set of configure-time Kconfig-fragment/devicetree-overlay paths
203
+ (relative POSIX, to `app_dir`) currently present under `app_dir` at one
204
+ of Zephyr's own auto-discovery locations. Returns the empty set for a
205
+ missing or unreadable `app_dir`, and for an unreadable `boards/`/`socs/`
206
+ subtree under it -- "nothing discoverable" is the same honest answer a
207
+ real Zephyr configure would reach, not an error this best-effort tracker
208
+ should raise.
209
+
210
+ An ABSENT `boards/`/`socs/` is the ordinary case, not a failure: it
211
+ contributes nothing and the app-root fragments still come back. That is
212
+ the inner handler's whole job, and it is why the outer one cannot simply
213
+ swallow every `OSError` from the whole walk."""
214
+ found: set[str] = set()
215
+ try:
216
+ found |= _candidate_files(app_dir, app_dir, recurse=False)
217
+ for subtree in _CANDIDATE_SUBTREES:
218
+ try:
219
+ found |= _candidate_files(app_dir / subtree, app_dir, recurse=True)
220
+ except (FileNotFoundError, NotADirectoryError):
221
+ continue
222
+ except OSError:
223
+ return frozenset()
224
+ return frozenset(found)
225
+
226
+
227
+ def configure_inputs_stamp_path(slice_cwd: Path) -> Path:
228
+ """Path of the tan-owned configure-inputs stamp for one slice."""
229
+ return slice_cwd / "build" / _CONFIGURE_INPUTS_STAMP_FILE
230
+
231
+
232
+ def read_configure_inputs_stamp(slice_cwd: Path) -> frozenset[str] | None:
233
+ """The configure-input file set recorded at the last stamp write, or
234
+ `None` for "never stamped", "unreadable", or "unreadable as UTF-8" --
235
+ all three read as "no signal", which the caller treats conservatively
236
+ (as a change, forcing one reset) rather than trusting a cache it cannot
237
+ actually verify. An empty stamped file (no candidate files at all) reads
238
+ back as the real empty set, `frozenset()`, not `None`."""
239
+ try:
240
+ text = configure_inputs_stamp_path(slice_cwd).read_text(encoding="utf-8")
241
+ except (OSError, UnicodeDecodeError):
242
+ return None
243
+ return frozenset(line for line in text.splitlines() if line)
244
+
245
+
246
+ def write_configure_inputs_stamp(slice_cwd: Path, inputs: frozenset[str]) -> None:
247
+ """Write the stamp BEFORE the tool is spawned (see the caller): a
248
+ mid-configure failure still stamped correctly, since the discoverable
249
+ set on disk really was what `inputs` records regardless of whether the
250
+ build finishes. One relative path per line, sorted for a stable diff.
251
+ Raises `OSError` on failure -- the caller treats the write as
252
+ best-effort (a write failure just means the next run may treat this dir
253
+ as unstamped: fails toward a spurious extra reset, never toward trusting
254
+ a stale cache)."""
255
+ path = configure_inputs_stamp_path(slice_cwd)
256
+ path.parent.mkdir(parents=True, exist_ok=True)
257
+ path.write_text("\n".join(sorted(inputs)) + ("\n" if inputs else ""), encoding="utf-8")
258
+
259
+
260
+ def _user_defines_stamp_path(slice_cwd: Path) -> Path:
261
+ return slice_cwd / "build" / ".tan-user-defines"
262
+
263
+
264
+ def read_user_defines_stamp(slice_cwd: Path) -> list[str] | None:
265
+ """The user `-D` key names of the last configure, or `None` if unstamped."""
266
+ try:
267
+ text = _user_defines_stamp_path(slice_cwd).read_text(encoding="utf-8")
268
+ except (OSError, UnicodeDecodeError):
269
+ return None
270
+ return [line for line in text.splitlines() if line]
271
+
272
+
273
+ def write_user_defines_stamp(slice_cwd: Path, keys: list[str]) -> None:
274
+ """Best-effort for the caller (raises `OSError`), like the inputs stamp."""
275
+ path = _user_defines_stamp_path(slice_cwd)
276
+ path.parent.mkdir(parents=True, exist_ok=True)
277
+ path.write_text("\n".join(sorted(keys)) + ("\n" if keys else ""), encoding="utf-8")