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
tan/core/venv.py ADDED
@@ -0,0 +1,332 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Workspace-venv resolution, shared by every command that spawns a tool the
3
+ `tan bootstrap` venv owns.
4
+
5
+ Port of `crates/tan-cli/src/venv.rs`. `west` normally lives ONLY inside the
6
+ bootstrapped venv -- nothing activates it for a GUI-launched editor, so the
7
+ ambient PATH has no `west` at all. This module is what lets `tan` find it
8
+ anyway.
9
+
10
+ Moved here from `tan.commands.build_cmd` (tan-cli#289): `_find_workspace_venv`
11
+ was private to that module but already imported across two others
12
+ (`west_forward_cmd.py`, and indirectly `generate_cmd.py`/`model_cmd.py`/
13
+ `monitor_cmd.py`/`new_som_cmd.py` via `_planner_python`) -- and three more
14
+ consumers (`build.execute`'s slice dispatch, `flash_cmd`'s tool gate and
15
+ spawn) needed the SAME search wired in rather than a second copy of it. This
16
+ is the one shared module `flash_cmd.py`'s own docstring asked for.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ from pathlib import Path
22
+
23
+ from tan.core.bootstrap import VenvLayout, manifest_points_at, venv_layout
24
+
25
+
26
+ def _resolve_layout(venv: Path) -> VenvLayout | None:
27
+ """Which bin directory actually has `west`, POSIX first -- the SAME
28
+ directory-wins rule creation already applies
29
+ (`bootstrap_cmd.Workspace.venv_bin`/`venv_present`), so a
30
+ Git-Bash/MSYS-created `Scripts/` venv (or a `bin/` venv found on a
31
+ Windows host) that bootstrap accepted is not invisible here. `None` when
32
+ `west` is present under neither layout.
33
+
34
+ Deliberately NOT `venv_layout(os.name == "nt")` (host-only): that was
35
+ tan-cli#291 -- bootstrap already derives the executable NAMES from the
36
+ winning directory (`core.bootstrap.venv_exe_names`), not the host, and
37
+ resolution disagreeing let a venv bootstrap just populated be invisible
38
+ to `build`/`flash`. The Rust oracle (`crates/tan-cli/src/venv.rs`) is
39
+ still host-only; tan-cli#291 identifies that as a latent oracle bug this
40
+ port inherited, not a divergence to keep copying now that creation has
41
+ already fixed it on its side (`3879cae`, tan-cli#289).
42
+ """
43
+ for is_windows in (False, True):
44
+ layout = venv_layout(is_windows)
45
+ if (venv / layout.bin_dir / layout.west).is_file():
46
+ return layout
47
+ return None
48
+
49
+
50
+ def find_workspace_venv(start: str, sdk_root: str | None) -> Path | None:
51
+ """Locate the west-capable workspace `.venv`, mirroring Rust's
52
+ `find_workspace_venv` but resolving the bin-dir layout directory-wins
53
+ (see `_resolve_layout`) rather than host-only: gated on `west` actually
54
+ being present under EITHER layout (not just the directory existing), in
55
+ this order:
56
+
57
+ 1. a `.venv` in the project tree, searched from `start` upward;
58
+ 2. the workspace venv derived from `$ZEPHYR_BASE`
59
+ (`<ZEPHYR_BASE>/../.venv`), manifest-guarded against `sdk_root` (see
60
+ `_zephyr_base_venv`, tan-cli#292 consequence 2);
61
+ 3. the SDK's canonical `<sdk-parent>/.venv` (post-alp-sdk#782) or the
62
+ legacy `<sdk-parent>/zephyrproject/.venv`.
63
+
64
+ `None` when none resolve (CI, an activated venv, the contract harness).
65
+
66
+ **tan-cli#495 defect 1: step 1 skips an ancestor that IS a west topdir
67
+ whose manifest is not alp-sdk's.** Step 1 used to accept any `.venv` with
68
+ a `west` in it, with no `sdk_root` involvement at all, while the sibling
69
+ resolver `west_workspace_dir` (below) rejects that same ancestor via its
70
+ tan-cli#307 `manifest_ok` guard -- so on a dirty host the two DISAGREED:
71
+ `tan build` spawned the ancestor venv's `west` with `cwd` pinned to the
72
+ correct workspace, and baked the ancestor venv's `python` into every
73
+ Zephyr slice as `-DPython3_EXECUTABLE`. That interpreter has none of
74
+ `zephyr/scripts/requirements.txt`, so `west build` died inside Zephyr's
75
+ own CMake configure naming Zephyr rather than tan -- the exact class
76
+ tan-cli#307 closed for the topdir, mirrored onto the binary. Worse when
77
+ the ancestor is a FULL Zephyr venv: no failure at all, just a silent
78
+ build against a different Zephyr's pinned requirements.
79
+
80
+ Deliberately NOT a naive mirror of #307's guard: `manifest_points_at` is
81
+ False for a project directory with no `.west` at all, so requiring it
82
+ outright would reject the SUPPORTED project-local `.venv` -- the exact
83
+ shape #307's own e2e test plants at `build_root/.venv`. The guard is
84
+ therefore conditional on the candidate's directory actually HOLDING a
85
+ `.west`: a directory that claims to be a west topdir must be the right
86
+ one, and a directory that claims nothing is judged on its `.venv` alone,
87
+ exactly as before.
88
+ """
89
+ directory: Path | None = Path(start)
90
+ while directory is not None:
91
+ candidate = directory / ".venv"
92
+ if _resolve_layout(candidate) is not None and _not_a_foreign_topdir(directory, sdk_root):
93
+ return candidate
94
+ parent = directory.parent
95
+ directory = parent if parent != directory else None
96
+
97
+ zephyr_base = os.environ.get("ZEPHYR_BASE")
98
+ if zephyr_base:
99
+ candidate = _zephyr_base_venv(Path(zephyr_base), sdk_root)
100
+ if candidate is not None:
101
+ return candidate
102
+
103
+ if sdk_root is not None:
104
+ parent = Path(sdk_root).parent
105
+ for workspace in (parent, parent / "zephyrproject"):
106
+ candidate = workspace / ".venv"
107
+ if _resolve_layout(candidate) is not None:
108
+ return candidate
109
+
110
+ return None
111
+
112
+
113
+ def _not_a_foreign_topdir(directory: Path, sdk_root: str | None) -> bool:
114
+ """Whether `directory` may supply the workspace venv (tan-cli#495 defect 1).
115
+
116
+ True in the two cases that carry no claim to check -- `sdk_root` did not
117
+ resolve, so there is nothing to verify against (the same "nothing to check"
118
+ fallback `_zephyr_base_venv` and `west_workspace_dir.manifest_ok` already
119
+ apply), or `directory` is not a west topdir, which is the ordinary
120
+ project-local `.venv`. False ONLY for a directory that really is a west
121
+ topdir whose manifest does not point at `sdk_root`: that one is a different
122
+ workspace, and its venv is a different Zephyr's.
123
+
124
+ **Topdir-ness is `.west/config`, not the `.west` DIRECTORY.** An earlier
125
+ cut tested `(directory / ".west").is_dir()` and broke `tan doctor`'s
126
+ `venvProvenance` check outright: tan writes its OWN provenance record to
127
+ `<workspace>/.west/tan-workspace-sdk` (`bootstrap_cmd.
128
+ record_workspace_sdk`), so a workspace carrying that record but no west
129
+ config -- every workspace between `record_workspace_sdk` and a completed
130
+ `west init` -- has a `.west` directory that is not a topdir. It was judged
131
+ foreign, its venv was refused, and the check vanished from the report
132
+ instead of passing. `manifest_points_at` reads `.west/config`, so keying on
133
+ that same file is what makes the guard and its verdict agree.
134
+ """
135
+ if sdk_root is None or not (directory / ".west" / "config").is_file():
136
+ return True
137
+ return manifest_points_at(directory, Path(sdk_root))
138
+
139
+
140
+ def _zephyr_base_venv(zephyr_base: Path, sdk_root: str | None) -> Path | None:
141
+ """Step 2 of `find_workspace_venv`: `zephyr_base`'s PARENT's `.venv`, but
142
+ only when it is both west-capable AND (when `sdk_root` resolved) its
143
+ workspace manifest is verifiably alp-sdk's -- port of the SAME guard
144
+ `_zephyr_base_workspace` already applies to the topdir search
145
+ (tan-cli#61), which this venv search lacked (tan-cli#292 consequence 2,
146
+ kept consistent with the tan-cli#301 fix one layer up: both now source
147
+ "which Zephyr/venv" from the RESOLVED workspace rather than a bare
148
+ `$ZEPHYR_BASE` read).
149
+
150
+ Without this, a stale exported `$ZEPHYR_BASE` naming an unrelated Zephyr
151
+ workspace shadowed the canonical `<sdk-parent>/.venv` (step 3) even when
152
+ the caller passed an explicit `--sdk-root` -- reproduced against the
153
+ published v0.5.0-rc2 asset on a clean host with no alp-sdk checkout
154
+ anywhere (tan-cli#278). `sdk_root` absent means there is nothing to
155
+ verify against, so the old unconditional accept stands, exactly as
156
+ `_zephyr_base_workspace` does.
157
+ """
158
+ workspace = zephyr_base.parent
159
+ candidate = workspace / ".venv"
160
+ if _resolve_layout(candidate) is None:
161
+ return None
162
+ if sdk_root is not None:
163
+ if not manifest_points_at(workspace, Path(sdk_root)):
164
+ return None
165
+ return candidate
166
+
167
+
168
+ def venv_bin_dir(start: str, sdk_root: str | None) -> Path | None:
169
+ """The west-capable workspace venv's executable directory (`bin` or
170
+ `Scripts`, chosen directory-wins -- see `_resolve_layout`, tan-cli#291).
171
+ This is the handle a spawning command needs: the directory to look tool
172
+ names up in, and the directory to put on the child's PATH. `None` when no
173
+ west-capable venv resolves."""
174
+ venv = find_workspace_venv(start, sdk_root)
175
+ if venv is None:
176
+ return None
177
+ layout = _resolve_layout(venv)
178
+ assert layout is not None # find_workspace_venv only returns what _resolve_layout accepted
179
+ return venv / layout.bin_dir
180
+
181
+
182
+ def west_workspace_dir(start: str, sdk_root: Path | None) -> Path | None:
183
+ """Port of `workspace.rs::west_workspace_dir`: the directory holding
184
+ `.west/` that a `west alp-*` extension command -- and `tan flash`'s
185
+ spawned children (tan-cli#289/#61), and (tan-cli#307) `tan build`'s own
186
+ `west build` children -- must run from. Discovered only from a workspace
187
+ manifest, never the app dir alone. Checked in order: the project tree
188
+ upward (manifest-verified against `sdk_root` when one resolved --
189
+ tan-cli#307, see `_manifest_ok` below), `$ZEPHYR_BASE/..`
190
+ (manifest-verified the same way), then the SDK-derived layouts
191
+ (`<sdk-parent>` and the legacy `<sdk-parent>/zephyrproject`, never
192
+ manifest-checked -- they are already anchored on `sdk_root` itself, with
193
+ nothing independent left to verify). `None` when nothing resolves -- the
194
+ caller then keeps the old app-dir cwd, matching the oracle exactly.
195
+
196
+ **tan-cli#307: the upward walk is manifest-guarded too, not just
197
+ `$ZEPHYR_BASE`.** A project sitting inside a directory that ALSO holds an
198
+ unrelated `.west` (a second Zephyr checkout, a vendor SDK, an old
199
+ workspace) used to have that ancestor win here purely because it sits
200
+ closer to `start` than the workspace `tan bootstrap` actually created
201
+ elsewhere -- the same class of bug tan-cli#61/#292 already fixed for the
202
+ `$ZEPHYR_BASE` candidate, now closed for the plain upward walk too.
203
+
204
+ **Moved here from `tan.commands.west_forward_cmd` (tan-cli#289 review).**
205
+ `flash_cmd.py` needed the SAME resolver `west_forward_cmd.py` already
206
+ had -- a second copy risked the two disagreeing the moment one changed,
207
+ the exact class of bug `find_workspace_venv` (above) already exists to
208
+ avoid. `west_forward_cmd` keeps a `_west_workspace_dir` alias bound to
209
+ this function for its own call site and its existing tests; a THIRD,
210
+ partial copy still lives in `tan.commands.doctor_cmd._resolve_
211
+ west_workspace_dir` and is a follow-up, not retired here.
212
+ """
213
+
214
+ def is_workspace(d: Path) -> bool:
215
+ return (d / ".west").is_dir()
216
+
217
+ def manifest_ok(d: Path) -> bool:
218
+ """`sdk_root` absent means there is nothing to verify against, so
219
+ `d` is accepted unconditionally -- the same "nothing to check"
220
+ fallback every other candidate in this function already applies."""
221
+ if sdk_root is None:
222
+ return True
223
+ return manifest_points_at(d, sdk_root)
224
+
225
+ directory: Path | None = Path(start)
226
+ while directory is not None:
227
+ if is_workspace(directory) and manifest_ok(directory):
228
+ return directory
229
+ parent = directory.parent
230
+ directory = parent if parent != directory else None
231
+
232
+ zephyr_base = os.environ.get("ZEPHYR_BASE")
233
+ if zephyr_base:
234
+ found = _zephyr_base_workspace(Path(zephyr_base), sdk_root)
235
+ if found is not None:
236
+ return found
237
+
238
+ if sdk_root is not None:
239
+ parent = sdk_root.parent
240
+ for workspace in (parent, parent / "zephyrproject"):
241
+ if is_workspace(workspace):
242
+ return workspace
243
+
244
+ return None
245
+
246
+
247
+ def _zephyr_base_workspace(zephyr_base: Path, sdk_root: Path | None) -> Path | None:
248
+ """Step 2 of `west_workspace_dir`: port of `workspace.rs::zephyr_base_workspace`.
249
+ `zephyr_base`'s PARENT, but only when it is both a west workspace AND (when
250
+ `sdk_root` resolved) its manifest is verifiably alp-sdk's -- a stock/unrelated
251
+ Zephyr checkout's parent is a west workspace by the bare `.west`-dir test
252
+ alone, yet carries no alp-sdk extension commands at all. `sdk_root` absent
253
+ means there is nothing to verify against, so the old unconditional accept
254
+ stands rather than refusing a workspace this call can't check.
255
+ """
256
+ workspace = zephyr_base.parent
257
+ if not (workspace / ".west").is_dir():
258
+ return None
259
+ if sdk_root is not None:
260
+ if not manifest_points_at(workspace, sdk_root):
261
+ return None
262
+ return workspace
263
+
264
+
265
+ def tool_in_venv(bin_dir: Path, tool: str) -> str | None:
266
+ """Resolve `tool` INSIDE an already-located venv bin dir: returns its
267
+ absolute path when it is really a file there. `.exe` is appended when
268
+ `bin_dir` IS the Windows-layout dir (its name matches
269
+ `venv_layout(True).bin_dir`, i.e. `Scripts`) unless the caller already
270
+ spelled it -- keyed on the directory that won (tan-cli#291), not
271
+ `os.name`: `bin_dir` is whatever `_resolve_layout`'s directory-wins probe
272
+ picked, which can be `Scripts/` on a POSIX-reporting host or `bin/` on
273
+ Windows when that is the layout actually on disk. `None` means "this venv
274
+ does not provide that tool" -- the caller then keeps its PATH behaviour
275
+ instead of spawning a path that doesn't exist."""
276
+ name = tool
277
+ if bin_dir.name == venv_layout(True).bin_dir and not tool.lower().endswith(".exe"):
278
+ name = f"{tool}.exe"
279
+ candidate = bin_dir / name
280
+ return str(candidate) if candidate.is_file() else None
281
+
282
+
283
+ def west_program(start: str, sdk_root: str | None) -> str:
284
+ """Resolve the `west` program to launch: the west-capable workspace
285
+ venv's `west` binary (see [`find_workspace_venv`]), falling back to
286
+ `"west"` on PATH when none resolve (CI, an activated venv, the contract
287
+ harness) -- behaving exactly as before in those environments. Mirrors
288
+ Rust's `west_program`."""
289
+ bin_dir = venv_bin_dir(start, sdk_root)
290
+ if bin_dir is None:
291
+ return "west"
292
+ return tool_in_venv(bin_dir, "west") or "west"
293
+
294
+
295
+ def venv_python(start: str, sdk_root: str | None) -> str | None:
296
+ """The west-capable workspace venv's `python` (see
297
+ [`find_workspace_venv`]), mirroring Rust's `venv_python`. `None` when no
298
+ workspace venv resolves, or the one that does has no `python` binary --
299
+ the caller then keeps its own PATH-name fallback rather than spawning a
300
+ path that doesn't exist.
301
+
302
+ Forward-slashed: a caller substitutes this verbatim into `${PYTHON}`,
303
+ which lands in a `-DPython3_EXECUTABLE=<...>` CMake `-D` argument, and a
304
+ Windows backslash there is an escape character (alp-sdk#849).
305
+ """
306
+ bin_dir = venv_bin_dir(start, sdk_root)
307
+ if bin_dir is None:
308
+ return None
309
+ resolved = tool_in_venv(bin_dir, "python")
310
+ return resolved.replace("\\", "/") if resolved is not None else None
311
+
312
+
313
+ def prepend_path(env: dict[str, str], directory: Path) -> dict[str, str]:
314
+ """Put `directory` at the FRONT of `env`'s `PATH`, mirroring Rust's
315
+ `prepend_path`. Returns a NEW dict -- `env` itself is never mutated, so a
316
+ caller holding onto the original (e.g. `dict(os.environ)`) is unaffected."""
317
+ existing = env.get("PATH", "")
318
+ joined = os.pathsep.join([str(directory), existing]) if existing else str(directory)
319
+ return {**env, "PATH": joined}
320
+
321
+
322
+ def with_venv_on_path(env: dict[str, str], tool: str) -> dict[str, str]:
323
+ """When `tool` resolved to an absolute (venv) path, prepend its
324
+ directory to `env`'s `PATH` -- mirroring Rust's `with_venv_on_path`. The
325
+ `alp-*`/`west` program spawned at that path may itself shell a nested
326
+ `west`/`bitbake`, which resolves purely via PATH. A bare tool name (the
327
+ PATH fallback) is left untouched, matching the oracle's early return for
328
+ a non-absolute `tool`."""
329
+ bin_path = Path(tool)
330
+ if not bin_path.is_absolute():
331
+ return env
332
+ return prepend_path(env, bin_path.parent)
@@ -0,0 +1,143 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Pure decisions for `tan bootstrap`'s `zephyr/patches.yml` phase (tan-cli#1296).
3
+
4
+ No IO here: `tan.commands.bootstrap_patches` owns every spawn and feeds the
5
+ results through these functions, so the exit-code contract of alp-sdk's
6
+ `scripts/verify_west_patches.py` lives in exactly one testable place.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import os
12
+ import re
13
+ from dataclasses import dataclass
14
+
15
+ #: The verifier's exit codes (its module docstring is the authority).
16
+ VERIFY_APPLIED = 0
17
+ #: Everything this workspace carries is patched, but a module `patches.yml`
18
+ #: names is not checked out here, so it could not be inspected. Normal for a
19
+ #: narrow workspace -- a warning, never a failure.
20
+ VERIFY_UNCHECKED = 3
21
+ #: Nothing could be inspected (no workspace, unreadable/empty `patches.yml`).
22
+ VERIFY_UNRUNNABLE = 2
23
+
24
+ #: Bounds for the output excerpt put into an issue message.
25
+ _TAIL_LINES = 12
26
+ _TAIL_CHARS = 1500
27
+
28
+
29
+ def classify_verify(returncode: int | None) -> str:
30
+ """`"applied"` / `"unchecked"` / `"unapplied"` / `"unrunnable"` for one run
31
+ of the verifier, before OR after the per-module applies.
32
+
33
+ `"unrunnable"` is exit 2 ("nothing could be inspected": no workspace,
34
+ unreadable `patches.yml`) or `None` (the verifier never launched). It is NOT
35
+ "unapplied": the patch state is unknown, and saying "not applied" would send
36
+ the reader to the wrong place. Re-verifying after the applies matters
37
+ because `west patch apply` exiting 0 is not evidence it applied anything
38
+ (it exits 0 on a missing `patches.yml`, an empty patch list, and an
39
+ unresolvable `module:`).
40
+ """
41
+ if returncode == VERIFY_APPLIED:
42
+ return "applied"
43
+ if returncode == VERIFY_UNCHECKED:
44
+ return "unchecked"
45
+ if returncode == VERIFY_UNRUNNABLE or returncode is None:
46
+ return "unrunnable"
47
+ return "unapplied"
48
+
49
+
50
+ def parse_unapplied(stdout: str) -> list[str]:
51
+ """Module names from `--list-unapplied` (one per line), de-duplicated in
52
+ first-seen order. Blank lines are ignored."""
53
+ seen: dict[str, None] = {}
54
+ for line in stdout.splitlines():
55
+ name = line.strip()
56
+ if name:
57
+ seen.setdefault(name, None)
58
+ return list(seen)
59
+
60
+
61
+ def output_tail(text: str) -> str:
62
+ """The last few lines of child output, bounded, for an issue message --
63
+ the cause (`patch does not apply`, a `pykwalify` ImportError) is at the
64
+ end, so the head is what gets dropped."""
65
+ lines = [line.rstrip() for line in text.strip().splitlines() if line.strip()]
66
+ tail = "\n".join(lines[-_TAIL_LINES:])
67
+ return tail[-_TAIL_CHARS:]
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # Workspace patch CHECK (tan-cli#1376): `tan doctor` / `tan build` ask the SAME
72
+ # verifier "is `zephyr/patches.yml` applied" without applying anything.
73
+ # ---------------------------------------------------------------------------
74
+
75
+ #: One failing line of the verifier's stderr: ` ABSENT <patch rel path>`.
76
+ _FAILURE_LINE = re.compile(r"^\s+(ABSENT|DRIFTED|UNRESOLVED)\s+(\S+)\s*$")
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class UnappliedPatch:
81
+ """One patch the verifier reports as not applied. `verdict` is the
82
+ verifier's own word (`ABSENT`/`DRIFTED`/`UNRESOLVED`); `patch` is the path
83
+ under `zephyr/patches/` it prints (e.g. `hal_alif/0001-....patch`)."""
84
+
85
+ verdict: str
86
+ patch: str
87
+
88
+
89
+ def parse_unapplied_patches(stderr: str) -> list[UnappliedPatch]:
90
+ """The patches named by the verifier's failure report, in report order."""
91
+ found: list[UnappliedPatch] = []
92
+ for line in stderr.splitlines():
93
+ m = _FAILURE_LINE.match(line)
94
+ if m is not None:
95
+ found.append(UnappliedPatch(m.group(1), m.group(2)))
96
+ return found
97
+
98
+
99
+ def describe_unapplied(patches: list[UnappliedPatch], modules: list[str]) -> str:
100
+ """`hal_alif/0001-x.patch (ABSENT), ...` plus the modules to patch."""
101
+ names = ", ".join(f"{p.patch} ({p.verdict})" for p in patches) or "unnamed patches"
102
+ where = f" in module(s) {', '.join(modules)}" if modules else ""
103
+ return f"{names}{where}"
104
+
105
+
106
+ def patch_fix_text(modules: list[str], workspace_dir: str) -> str:
107
+ """The exact remedy: `tan bootstrap` applies exactly the unapplied modules
108
+ (verify, `west patch --dst-module <m> apply`, re-verify). The per-module
109
+ west form is the manual equivalent; a bare `west patch apply` is NOT
110
+ offered because it re-applies already-patched modules and fails."""
111
+ manual = (
112
+ "; ".join(f"west patch --dst-module {m} apply" for m in modules)
113
+ or "west patch --dst-module <module> apply"
114
+ )
115
+ return f"run `tan bootstrap` (or, from {workspace_dir}: {manual})"
116
+
117
+
118
+ def cache_key(patches_yml: bytes, heads: dict[str, str]) -> str:
119
+ """Stable digest of (`patches.yml` bytes, every workspace module's HEAD)."""
120
+ h = hashlib.sha256()
121
+ h.update(hashlib.sha256(patches_yml).digest())
122
+ for path in sorted(heads):
123
+ h.update(f"\0{path}\0{heads[path]}".encode())
124
+ return h.hexdigest()
125
+
126
+
127
+ def zephyr_base_note(env_value: str | None, workspace_zephyr: str | None) -> str | None:
128
+ """Message when `$ZEPHYR_BASE` is set and names a different tree than the
129
+ resolved workspace's `zephyr/`; `None` otherwise. tan never honours the
130
+ override, so the user must hear it is being ignored."""
131
+ if not env_value or workspace_zephyr is None:
132
+ return None
133
+
134
+ def norm(p: str) -> str:
135
+ return os.path.normcase(os.path.realpath(p))
136
+
137
+ if norm(env_value) == norm(workspace_zephyr):
138
+ return None
139
+ return (
140
+ f"$ZEPHYR_BASE={env_value} is ignored: tan always builds with the resolved west "
141
+ f"workspace's zephyr ({workspace_zephyr}). To build against another tree, run "
142
+ "tan from that workspace or bootstrap it."
143
+ )
@@ -0,0 +1,159 @@
1
+ """tan-cli#1429: refuse a `west build` slice up front when nothing will find a
2
+ west workspace for it, instead of letting west fail with its own
3
+ `west: unknown command "build"; do you need to run this inside a workspace?`.
4
+
5
+ The shape the issue reproduced: `--sdk-root` names an alp-sdk checkout that is
6
+ NOT the manifest project of any west workspace tan can see (a sibling git
7
+ worktree such as `~/.cache/sdk-dev-2784`), and the app lives outside every
8
+ workspace tree (`/tmp/...`). `tan.core.venv.west_workspace_dir` then returns
9
+ `None`, so `execute_slices` keeps the slice's own cwd (tan-cli#307's fallback),
10
+ and west -- finding no `.west` above that cwd -- never loads the `build`
11
+ extension at all. The envelope carried only the generic `build.slice-failed`.
12
+
13
+ **Keyed on what WEST will do, not on what tan resolved.** `workspace_dir is
14
+ None` alone is not a failure: an app inside some workspace tree still builds,
15
+ because west's own ancestor walk finds that `.west` without tan's manifest
16
+ guard (the "move it under the workspace and it builds" observation in the
17
+ issue), and an ambient `ZEPHYR_BASE` gives west a second place to look. The
18
+ refusal therefore fires only when neither the spawn cwd nor the spawn env's
19
+ `ZEPHYR_BASE` has a `.west` on any ancestor -- the case where west cannot
20
+ succeed. Anything less certain is left to west exactly as before.
21
+
22
+ **Reusing a workspace is deliberately not attempted.** tan has no record that
23
+ maps an SDK checkout to a workspace (`.west/tan-workspace-sdk` maps the other
24
+ way and sits inside the workspace), so picking one would be a guess -- the
25
+ thing tan-cli#307/#61/#292's manifest guard exists to refuse.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from dataclasses import dataclass
31
+ from pathlib import Path
32
+
33
+ from tan.core.bootstrap import get_manifest_path
34
+
35
+ #: The marker `build_cmd._workspace_unresolved_issues` matches to promote the
36
+ #: refused slice's reason into the coded `build.workspace-unresolved` issue,
37
+ #: the same idiom as `tan.core.plan_exec.CROSS_DRIVE_MSG`.
38
+ WORKSPACE_UNRESOLVED_MSG = "no west workspace resolves for this build"
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class WorkspaceRefusal:
43
+ """`message` carries absolute paths for this run's stdout and envelope;
44
+ `manifest_message` is the short form `system-manifest.yaml` keeps, without
45
+ the host's directory layout (same split as `CrossDriveRefusal`)."""
46
+
47
+ message: str
48
+ manifest_message: str
49
+
50
+
51
+ def west_ancestor(start: Path) -> Path | None:
52
+ """The nearest directory at or above `start` holding a `.west` directory,
53
+ or `None`. Mirrors west's own `west_topdir` walk: unguarded, because west
54
+ does not check which manifest a `.west` belongs to. `start` is resolved
55
+ (symlinks followed) because west walks from `os.getcwd()`, which POSIX
56
+ reports as the real path."""
57
+ start = Path(start).resolve()
58
+ for directory in (start, *start.parents):
59
+ if (directory / ".west").is_dir():
60
+ return directory
61
+ return None
62
+
63
+
64
+ def workspace_unresolved_refusal(
65
+ workspace_dir: Path | None,
66
+ args: list[str],
67
+ spawn_cwd: Path,
68
+ zephyr_base: str | None,
69
+ sdk_root: Path | None,
70
+ ) -> WorkspaceRefusal | None:
71
+ """`None` unless this `west build` slice cannot find a workspace.
72
+
73
+ `zephyr_base` is the value the SPAWN's env carries, not `os.environ`'s.
74
+ `sdk_root` absent is never refused: without it there is no checkout to
75
+ name, and no `tan bootstrap --sdk-root` remedy to give.
76
+ """
77
+ if workspace_dir is not None or sdk_root is None or not args or args[0] != "build":
78
+ return None
79
+ if west_ancestor(spawn_cwd) is not None:
80
+ return None
81
+ # A relative ZEPHYR_BASE is resolved by west against the spawn cwd.
82
+ if zephyr_base and west_ancestor(Path(spawn_cwd) / zephyr_base) is not None:
83
+ return None
84
+ message = (
85
+ f"{WORKSPACE_UNRESOLVED_MSG} -- `west build` would run from `{spawn_cwd}`, "
86
+ f"which has no `.west` on any ancestor; {_unresolved_facts(zephyr_base, sdk_root)}. "
87
+ f"west would stop with `unknown command \"build\"`. {_remedies(sdk_root)}"
88
+ )
89
+ return WorkspaceRefusal(message, f"{WORKSPACE_UNRESOLVED_MSG} (run `tan bootstrap`)")
90
+
91
+
92
+ def _unresolved_facts(zephyr_base: str | None, sdk_root: Path) -> str:
93
+ zephyr_base_fact = (
94
+ f"ZEPHYR_BASE `{zephyr_base}` has no `.west` above it either"
95
+ if zephyr_base
96
+ else "ZEPHYR_BASE is unset"
97
+ )
98
+ sdk_parent = sdk_root.parent
99
+ return (
100
+ f"{zephyr_base_fact}; and neither `{sdk_parent}` nor "
101
+ f"`{sdk_parent / 'zephyrproject'}` (next to the SDK root `{sdk_root}`) holds a `.west`"
102
+ )
103
+
104
+
105
+ def _remedies(sdk_root: Path) -> str:
106
+ return (
107
+ f"Run `tan bootstrap --sdk-root {sdk_root}` to create a workspace for this "
108
+ f"checkout, or set ZEPHYR_BASE to an existing workspace's `zephyr/` -- that "
109
+ f"builds against that workspace's Zephyr revision and patches, which need "
110
+ f"not match this checkout's `west.yml`."
111
+ )
112
+
113
+
114
+ def manifest_project(topdir: Path) -> Path | None:
115
+ """The manifest project `<topdir>/.west/config` names, or `None` when the
116
+ config is missing or names none."""
117
+ try:
118
+ config = (topdir / ".west" / "config").read_text(encoding="utf-8", errors="replace")
119
+ except OSError:
120
+ return None
121
+ rel = get_manifest_path(config)
122
+ return topdir / rel.strip() if rel else None
123
+
124
+
125
+ def unresolved_workspace_verdict(
126
+ start: Path, zephyr_base: str | None, sdk_root: Path
127
+ ) -> tuple[str, str]:
128
+ """tan-cli#1432: `tan doctor`'s `workspace` verdict when tan resolved no
129
+ workspace for `sdk_root`, as `(status, detail)`, agreeing with what
130
+ `tan build` will then do from `start`.
131
+
132
+ `warn` when west's own unguarded walk still lands on a `.west` -- above
133
+ `start` (west uses it; the build succeeds against THAT workspace's Zephyr),
134
+ or above `zephyr_base` (west's fallback, which is not verified to reach
135
+ the `build` extension, hence "may"). `fail` otherwise, naming the same
136
+ facts and remedies as `build.workspace-unresolved`.
137
+ """
138
+ sdk_root = Path(sdk_root)
139
+ found = west_ancestor(start)
140
+ lead = f"west will use `{found}`, a `.west` above `{Path(start).absolute()}`"
141
+ if found is None and zephyr_base:
142
+ # As `workspace_unresolved_refusal`: west resolves a relative
143
+ # ZEPHYR_BASE against the cwd it runs in, which is `start`.
144
+ found = west_ancestor(Path(start) / zephyr_base)
145
+ lead = f"west may fall back to `{found}`, a `.west` above ZEPHYR_BASE `{zephyr_base}`"
146
+ if found is None:
147
+ return "fail", (
148
+ f"no Zephyr workspace for the SDK root `{sdk_root}`: `{Path(start).absolute()}` has "
149
+ f"no `.west` on any ancestor; {_unresolved_facts(zephyr_base, sdk_root)}, so "
150
+ f"a `west build` slice is refused as `build.workspace-unresolved`. {_remedies(sdk_root)}"
151
+ )
152
+ names = manifest_project(found)
153
+ manifest = f"names `{names}` as its manifest" if names else "names no manifest project"
154
+ return "warn", (
155
+ f"no workspace has the SDK root `{sdk_root}` as its manifest, but {lead}, which "
156
+ f"{manifest} -- a build uses that workspace's Zephyr revision and patches, which "
157
+ f"need not match this checkout's `west.yml`. Run `tan bootstrap --sdk-root "
158
+ f"{sdk_root}` for a workspace of its own."
159
+ )