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,73 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """One wrapping seam every text-mode command can share.
3
+
4
+ Measured across the 24 read-only commands: 13 emit at least one line over
5
+ 100 columns (`doctor` 557, `monitor` 362, `size` 267, ...), and only
6
+ `build_cmd` (via stdlib `textwrap`) wraps anything at all. `doctor` is the
7
+ first command to be fixed; this module is the reusable seam the rest of that
8
+ sweep points at afterwards, so it carries no `doctor` concept -- a caller
9
+ supplies its own prefix and hanging indent, this just wraps text under them.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import textwrap
14
+
15
+
16
+ def wrap_block(body: str, width: int, initial_indent: str, hanging_indent: str) -> list[str]:
17
+ """Wrap `body` to `width` columns, `initial_indent` prefixing the first
18
+ line and `hanging_indent` every continuation line -- so a continuation
19
+ never starts at column 0 and reads as subordinate to the line above it.
20
+
21
+ `width` is a target, not a hard ceiling: `break_long_words=False` (see
22
+ below) means a single token that, plus whichever indent frames it, is
23
+ alone longer than `width` -- a SKU, a long flag, a path -- comes back
24
+ over-length and intact on its own line rather than being cut mid-
25
+ character. Every OTHER line this returns does fit `width`; a caller that
26
+ needs a hard cap on every line cannot assume that of this one.
27
+
28
+ Always returns at least one line (`[initial_indent]` for an empty
29
+ `body`), so a caller building `"{prefix}{first_wrapped_chunk}"` never has
30
+ to special-case an empty result.
31
+ """
32
+ # `break_on_hyphens`/`break_long_words` default True, which is wrong for
33
+ # this seam: this repo's house rule is that SKUs, part numbers, flags and
34
+ # paths are reproduced verbatim, and stdlib's defaults mangle exactly
35
+ # those -- `arm-zephyr-eabi` splits into `arm-` / `zephyr-eabi`,
36
+ # `--sdk-root` into `--sdk-` / `root`. Both off trades a hard `width`
37
+ # ceiling for that guarantee -- see the docstring above for the resulting
38
+ # overflow contract.
39
+ wrapped = textwrap.wrap(
40
+ body,
41
+ width=width,
42
+ initial_indent=initial_indent,
43
+ subsequent_indent=hanging_indent,
44
+ break_on_hyphens=False,
45
+ break_long_words=False,
46
+ )
47
+ return wrapped or [initial_indent]
48
+
49
+
50
+ def wrap_lines(lines: list[str], width: int | None, hanging_indent: str = " ") -> list[str]:
51
+ """Wrap every line in `lines` independently through `wrap_block`, no
52
+ prefix added to a first line (a caller that wants a bullet or label puts
53
+ it in the line's own text before calling this) and `hanging_indent` on
54
+ any continuation line it produces.
55
+
56
+ `width is None` -- stderr is not a terminal, see `tan.env.wrap_width` --
57
+ returns `lines` UNCHANGED: piped/redirected output must reproduce a
58
+ command's report byte-for-byte, which is what every existing
59
+ subprocess/golden test for `explain`/`sdk current` depends on. There is
60
+ no per-line classification here (an earlier version of this seam kept a
61
+ table of "record-shaped" lines exempt from wrapping, on the theory that
62
+ a piped reader might grep them) -- that reader can never observe a
63
+ wrapped line in the first place, since a pipe makes stderr not a tty and
64
+ this function returns `lines` verbatim in that case. Wrapping applies
65
+ uniformly instead; `break_long_words=False` on `wrap_block` already
66
+ keeps a long path/id/SKU intact on its own line rather than mangled.
67
+ """
68
+ if width is None:
69
+ return lines
70
+ out: list[str] = []
71
+ for line in lines:
72
+ out.extend(wrap_block(line, width, "", hanging_indent))
73
+ return out
tan/core/timestamp.py ADDED
@@ -0,0 +1,182 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """The port's `generatedAt`/`updatedAt` timestamp helpers. Both NEVER raise,
3
+ and both render the SAME stamp on every platform.
4
+
5
+ `generated_at_iso` lets `SOURCE_DATE_EPOCH` win over the clock, so a captured
6
+ envelope -- or a written `.alp/sdk-path` -- is reproducible. Counterpart of
7
+ `crate::util::generated_at_iso` + `tan_core::clock::format_iso8601_utc`.
8
+ `wall_clock_iso` (below `generated_at_iso`) deliberately does NOT: it is for
9
+ the one field whose job is to order two REAL writes against each other on one
10
+ host, where `SOURCE_DATE_EPOCH` reproducibility would defeat the field
11
+ instead of serving it. See its docstring before reaching for it, or before
12
+ "fixing" a stamp that looks non-reproducible back onto `generated_at_iso`.
13
+
14
+ Shared rather than per-command because three commands each carried their own
15
+ copy of these four lines, ONE copy was hardened against an out-of-range epoch,
16
+ and the other two shipped the defect. A timestamp helper that throws is not
17
+ cosmetic in this port -- each caller breaks differently and none of them
18
+ harmlessly:
19
+
20
+ * `tan debug-config` calls it from the recovery path of its own exception
21
+ guard, so a throw DOUBLE-FAULTS: the first failure is caught, the recovery
22
+ re-raises, and the process dies with a raw traceback and EMPTY stdout --
23
+ precisely the break that guard exists to prevent.
24
+ * `tan init` calls it through `tan.core.scaffold.sdk_pointer_json` while
25
+ writing `.alp/sdk-path`, i.e. AFTER the customer's project files already
26
+ landed.
27
+ * `tan doctor` calls it inside its own try/except, so it degrades to
28
+ `doctor.internal-failure` instead -- a fabricated "this host is broken"
29
+ verdict on a host that is fine.
30
+
31
+ The realistic trigger is `SOURCE_DATE_EPOCH` in MILLISECONDS (1700000000000 is
32
+ year 55840, not 2023), and CI and reproducible-build environments are exactly
33
+ what set that variable.
34
+
35
+ **Out of range is REFUSED, not rendered, and the range is `datetime`'s -- year
36
+ [1; 9999] -- on every platform.** The arithmetic is deliberately `datetime`'s
37
+ and not `time.gmtime`'s: gmtime's range is the platform's `time_t`, so the same
38
+ env var used to render `55840-11-08T22:13:20Z` on glibc and fall back to the
39
+ wall clock on Windows, whose CRT rejects the year. One input, two answers, from
40
+ a variable whose entire purpose is reproducibility -- and `253402300799`
41
+ (9999-12-31T23:59:59Z) split the same way in the other direction. `datetime`'s
42
+ range is fixed by the language, so the fallback now happens on the same inputs
43
+ everywhere. Note this is still catch-not-predict: the OverflowError is caught,
44
+ never anticipated -- only the range that raises it is now portable.
45
+
46
+ **This deliberately DIVERGES from the Rust oracle, which renders the 5-digit
47
+ year.** `tan_core::clock::format_iso8601_utc` does its own proleptic-Gregorian
48
+ arithmetic (Hinnant's `civil_from_days`) and formats `{year:04}`, which widens
49
+ rather than truncates, so the Rust emits `55840-11-08T22:13:20.000Z` and cannot
50
+ fail. Matching that would mean hand-rolling the same civil-date algorithm here
51
+ to emit a stamp no consumer can read back: `%Y` is exactly four digits (see
52
+ `_strptime.TimeRE`), and ECMAScript's Date Time String Format spells a year
53
+ past 9999 as `+055840`, not `55840`. The wall clock is a wrong-but-parseable
54
+ answer to an epoch that was already nonsense; a 5-digit year is an unparseable
55
+ one. Fixing the shared bug beats bug-for-bug parity here -- if the Rust is ever
56
+ unfrozen, it should refuse the same range.
57
+ """
58
+
59
+ from __future__ import annotations
60
+
61
+ import os
62
+ import time
63
+ from datetime import datetime, timedelta, timezone
64
+
65
+ #: Both output shapes fall back to the Unix epoch itself: a wrong-but-shaped
66
+ #: stamp keeps the envelope parseable, which is the whole point.
67
+ _FALLBACK = "1970-01-01T00:00:00"
68
+
69
+ _UNIX_EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)
70
+
71
+
72
+ def _render(moment: datetime, *, millis: bool) -> str:
73
+ return (
74
+ moment.replace(tzinfo=None).isoformat(timespec="milliseconds" if millis else "seconds")
75
+ + "Z"
76
+ )
77
+
78
+
79
+ def generated_at_iso(*, millis: bool = False) -> str:
80
+ """`SOURCE_DATE_EPOCH` when set and usable, else the wall clock, as
81
+ `YYYY-MM-DDTHH:MM:SSZ` -- or `YYYY-MM-DDTHH:MM:SS.mmmZ` with `millis=True`.
82
+
83
+ Two shapes because the callers' committed goldens pin two: `tan doctor`'s
84
+ `data.generatedAt` and `.alp/sdk-path`'s `updatedAt` carry seconds,
85
+ `tan debug-config`'s `data.generatedAt` carries milliseconds. Unifying the
86
+ WIRE shapes is a contract change plus a golden rewrite; unifying the code
87
+ behind them is not, so only the code is unified here.
88
+
89
+ An unparseable OR out-of-range value is the clock -- never an error,
90
+ because no caller of a timestamp is in a position to report one.
91
+
92
+ **For a field that ORDERS two writes against each other on ONE host,
93
+ use `wall_clock_iso` instead** -- this function's entire point is that a
94
+ captured envelope is reproducible, which is exactly wrong for a field
95
+ whose job is to tell two REAL writes apart.
96
+ """
97
+ seconds: float | int = time.time()
98
+ raw = os.environ.get("SOURCE_DATE_EPOCH")
99
+ if raw is not None:
100
+ try:
101
+ # Kept as an `int`, never floated: `float(int("1" * 400))` raises
102
+ # OverflowError, which this helper promises never to let out.
103
+ seconds = int(raw.strip())
104
+ except ValueError:
105
+ pass
106
+ for candidate in (seconds, time.time()):
107
+ try:
108
+ moment = _UNIX_EPOCH + timedelta(seconds=candidate)
109
+ except (OverflowError, OSError, ValueError):
110
+ continue # outside year [1; 9999] -- try the clock
111
+ return _render(moment, millis=millis)
112
+ # The supplied stamp AND the wall clock are both unusable: still no throw.
113
+ return f"{_FALLBACK}.000Z" if millis else f"{_FALLBACK}Z"
114
+
115
+
116
+ def wall_clock_iso(*, millis: bool = False) -> str:
117
+ """The literal wall clock, `SOURCE_DATE_EPOCH` deliberately NOT included --
118
+ same two wire shapes as `generated_at_iso`, same promise that it never
119
+ raises, but reproducibility is the WRONG promise for this one caller.
120
+
121
+ **The one caller is `tan.core.sdk_default_registry.with_entry`'s
122
+ `updated_at`**, via `bootstrap_cmd._write_global_sdk_registry`. That field
123
+ orders two REAL `tan bootstrap` runs against each other on ONE host's
124
+ mutable `~/.alp/sdk-defaults.json` -- it is machine-local runtime state,
125
+ never a build artefact, and nothing about it is meant to be byte-
126
+ reproducible across machines or CI runs. `SOURCE_DATE_EPOCH` winning here
127
+ would silently defeat the very field it stamps: every `tan bootstrap` run
128
+ inside one `SOURCE_DATE_EPOCH`-pinned shell -- exactly what CI and
129
+ reproducible-build setups export, see `generated_at_iso` above -- would
130
+ carry the IDENTICAL `updatedAt`, so `entry_updated_at > best_updated_at`
131
+ in `deepest_covering_entry` would never fire and resolution would fall
132
+ back to whichever raw origin string `dict`/`sort_keys=True` visits first
133
+ -- the exact accident-of-spelling tie tan-cli#904 second round fixed,
134
+ silently reopened by the one environment variable whose entire purpose is
135
+ determinism. Measured pre-fix, with `SOURCE_DATE_EPOCH` exported: two
136
+ bootstraps of DIFFERENT projects stamp the identical `updatedAt` and the
137
+ STALE alias entry answers every time, and lowering the variable BETWEEN
138
+ two runs makes the chronologically LATER bootstrap lose outright.
139
+
140
+ Wall-clock ordering is itself non-monotonic (an NTP step-back, a VM
141
+ snapshot restore) -- inherent, since this registry has no cross-process
142
+ monotonic key to use instead, so a clock that moves backward between two
143
+ real bootstraps can still tie-break the wrong way (review, #904 final
144
+ round, nit).
145
+
146
+ Every OTHER `generatedAt`/`updatedAt` field in this codebase -- all
147
+ EIGHT `generated_at_iso` call sites, enumerated (review, #904 final
148
+ round, nit -- an earlier revision of this list named 4 of the 8 and
149
+ called it exhaustive): `tan doctor` (`doctor_cmd.py`), `tan
150
+ debug-config` (`debug_config_cmd.py`), `.alp/sdk-path`
151
+ (`scaffold.sdk_pointer_json`), `<topdir>/.west/tan-workspace-sdk`
152
+ (`bootstrap.workspace_sdk_record_json`), the legacy
153
+ `~/.alp/sdk-default` pointer (`bootstrap_cmd.py`, the one sharing this
154
+ module's own resolution ladder), `tan inspect` (`inspect_cmd.py`), `tan
155
+ trace` (`trace_cmd.py`), and `tan support-bundle`'s two fields
156
+ (`support_bundle_cmd.py`) -- is informational display of a SINGLE
157
+ write, never compared against a sibling write, so `SOURCE_DATE_EPOCH`
158
+ reproducibility is exactly the right behaviour there -- this function
159
+ exists for the one field that is a comparison key, not a display
160
+ value. `sdk_discovery._pointer_target` reads `sdkPath` only, never
161
+ `updatedAt`; `bootstrap.parse_workspace_sdk_record` never reads
162
+ `updatedAt` either; and no mtime comparison exists anywhere in the
163
+ resolution ladder -- so none of the eight is secretly a second
164
+ comparison key this function should also be serving. If a future field
165
+ needs the same thing, it belongs here, not on `generated_at_iso`.
166
+
167
+ Never raises, but not for the reason an earlier revision of this
168
+ docstring gave (review, #904 final round, major): `time.time()` reads
169
+ the SYSTEM clock, which a host can set to any value a user or a faulty
170
+ RTC/NTP step chooses, including one past `datetime`'s year-9999 ceiling
171
+ -- "cannot land outside" was true of `SOURCE_DATE_EPOCH`'s validated
172
+ range in `generated_at_iso`, never of the raw wall clock. This function
173
+ now defends the same way: an out-of-range wall clock degrades to the
174
+ shared `_FALLBACK` epoch rather than raising, keeping the module
175
+ header's "Both NEVER raise" promise for this caller too, not just for
176
+ `generated_at_iso`'s env-var-supplied value.
177
+ """
178
+ try:
179
+ moment = _UNIX_EPOCH + timedelta(seconds=time.time())
180
+ except (OverflowError, OSError, ValueError):
181
+ return f"{_FALLBACK}.000Z" if millis else f"{_FALLBACK}Z"
182
+ return _render(moment, millis=millis)
@@ -0,0 +1,383 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """ONE "where is this tool" lookup, for every command that spawns one.
3
+
4
+ **tan-cli#567 / #532.** The hardened lookup lived in five independent
5
+ hand-rolled copies, and tan-cli#510 was caused by exactly that shape: an
6
+ availability CHECK that walked `%PATH%` by hand so the current directory could
7
+ not shadow a tool, and a SPAWN, in the same function, that handed the bare
8
+ identity back to the platform's own resolver -- whose Windows search order
9
+ (`CreateProcess` with `lpApplicationName=NULL`) puts *the current directory for
10
+ the parent process* AHEAD of `PATH`. The check protected against a hazard the
11
+ spawn then re-opened.
12
+
13
+ `resolve_tool` is that lookup, moved out of `tan.commands.build.execute` where
14
+ #510 hardened it, so the flash write path and the size path can spawn the SAME
15
+ resolved path instead of maintaining a third and fourth opinion about it. The
16
+ callers keep their own policy about what to do when a tool is missing; only the
17
+ *finding* is shared.
18
+
19
+ **Why not `shutil.which` on Windows.** Its stdlib implementation inserts
20
+ `os.curdir` ahead of every `PATH` entry ("the current directory takes
21
+ precedence on Windows" -- its own source comment), reproducing `CreateProcess`'s
22
+ native search order. `where.exe` behaves the same way. The Rust oracle
23
+ (`crates/tan-cli/src/util.rs::command_on_path`) walks `%PATH%` by hand
24
+ precisely so a project checked out with its own `west.exe`/`openocd.exe`/
25
+ `size.exe` at its root can never be picked up in place of the real tool; this
26
+ is that walk.
27
+
28
+ **The Windows candidate set -- the one deliberate behaviour change here.**
29
+ For a bare, extension-less identity this walk tries `tool` + each `%PATHEXT%`
30
+ suffix and NEVER `tool` itself ([`windows_candidate_names`]). That is a
31
+ Windows-arm behaviour change relative to `doctor_cmd.on_path`, one of the five
32
+ hand-rolled lookups #532 consolidates, whose extension list is `[""] + PATHEXT`
33
+ -- it accepts an extension-less `%PATH%` file, and accepts it AHEAD of every
34
+ suffixed sibling. Three reasons the oracle's shape is the one to consolidate on:
35
+
36
+ * **Oracle parity.** `crates/tan-cli/src/util.rs::find_on_path` is
37
+ `if has_ext { dir.join(command) } else { for ext in &exts { ... } }` -- no
38
+ bare candidate. The oracle is the fixed point this port is measured against,
39
+ and `build/execute.py::_resolve_tool` (the copy this module IS) already
40
+ agreed with it. Only `on_path` did not -- and since tan-cli#532 it no
41
+ longer exists as a separate walk: `doctor_cmd.on_path` delegates here, so
42
+ the divergence this section describes is now historical, and the behaviour
43
+ change it records has taken effect for the doctor and `faultdecode_cmd`
44
+ as well.
45
+ * **Windows itself never selects one for a bare identity.** `CreateProcess`
46
+ with `lpApplicationName=NULL` appends ONLY `.exe` to an unqualified name and
47
+ does not consult `%PATHEXT%` at all -- the documented reason
48
+ `subprocess.run(["npm"])` raises `FileNotFoundError` while `npm.cmd` sits on
49
+ `%PATH%` (measured in `tests/commands/test_execute.py::_copy_interpreter_as`).
50
+ `cmd.exe` and PowerShell resolve a typed command name through `%PATHEXT%`
51
+ too. This walk is meant to be a HARDENED version of that search -- more
52
+ permissive along one axis only (`%PATHEXT%`), never admitting files no
53
+ Windows resolver would produce for the name.
54
+ * **It would be unsafe now that the value is SPAWNED, not reduced to a bool.**
55
+ The extension-less files really found on a Windows `%PATH%` are
56
+ overwhelmingly POSIX shims -- `npm`/`yarn`/`git-*` sh scripts shipped beside
57
+ their `.cmd` sibling in the SAME directory -- and `""` going first means the
58
+ sh script wins. Since tan-cli#567 that value goes to `subprocess` as
59
+ `executable=`, i.e. `lpApplicationName` with no search, where a non-PE file
60
+ is `[WinError 193] %1 is not a valid Win32 application`. The leniency would
61
+ turn a working `.cmd` spawn into a hard spawn failure, and would make the
62
+ go/no-go gate approve a file the spawn cannot launch: the very check/spawn
63
+ disagreement #567 closes, re-entered from the other side.
64
+
65
+ What is given up is bare-identity DISCOVERY of an extension-less file that is
66
+ a valid PE. Windows will execute one of those -- `.exe` is appended only to a
67
+ name carrying no path -- and that capability is untouched here: an absolute
68
+ `tool` is answered by existence alone and spawned verbatim, so a plan may
69
+ still name one. A venv rewrite is the narrower case: `venv.tool_in_venv`
70
+ (`tan/core/venv.py:277`) appends `.exe` whenever the winning directory is
71
+ `Scripts`, which is what `_resolve_layout` picks for a stock Windows venv, so
72
+ a rewrite can only name an extension-less file in the `bin`-on-Windows layout.
73
+ Only finding it by bare name on `%PATH%` is refused, and the refusal names the
74
+ `%PATH%` it walked.
75
+ """
76
+ from __future__ import annotations
77
+
78
+ import os
79
+ import shutil
80
+ from collections.abc import Mapping
81
+ from dataclasses import dataclass
82
+ from pathlib import Path, PureWindowsPath
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class ToolResolution:
87
+ """One answer to both "is `tool` available" and "where is it" --
88
+ `resolved` is the absolute path to actually spawn (`None` when nothing
89
+ was found), `searched` is always populated, found or not, and describes
90
+ where this looked.
91
+
92
+ Replaces the old `_command_on_path`/`_tool_is_available` pair
93
+ (tan-cli#510): that pair answered a bare bool from one hardened lookup,
94
+ and the spawn below then repeated a SEPARATE, unhardened one -- handing
95
+ the bare identity straight to the platform's own resolver, whose Windows
96
+ search order includes a current-directory step the availability check
97
+ was written specifically to exclude. One resolver now; the check and the
98
+ spawn can never again disagree about what ran."""
99
+
100
+ resolved: str | None
101
+ searched: str
102
+
103
+
104
+ def resolve_tool(tool: str, env: Mapping[str, str]) -> ToolResolution:
105
+ """Resolve `tool` -- an identity per ADR-0020 (never a path in a
106
+ well-formed plan), or an absolute path a caller already resolved
107
+ (`west_program`'s workspace-venv rewrite, `execute_slices`;
108
+ `flash_cmd._programs_resolved_in_venv`) -- to the absolute path to spawn.
109
+
110
+ An absolute `tool` is answered by existence alone: whoever produced it
111
+ already did the searching, and re-walking PATH for something that is
112
+ already a path would be wrong.
113
+
114
+ A bare/relative name is walked exactly the way the old
115
+ `_command_on_path` did -- this IS that lookup, now doubling as the
116
+ resolution the spawn itself uses instead of a second, divergent one:
117
+ POSIX via `shutil.which` (no CWD special-case there); Windows via a
118
+ hand-rolled `%PATH%` walk, deliberately NOT `shutil.which` -- see this
119
+ module's own docstring for both halves of that reasoning. The candidate
120
+ NAMES that walk tries are [`windows_candidate_names`], which is where the
121
+ one deliberate Windows-arm behaviour change this consolidation makes (an
122
+ extensionless PATH file is no longer a hit, matching the oracle rather
123
+ than `doctor_cmd.on_path`) is recorded and justified.
124
+
125
+ `env` -- MAJOR 2 of the tan-cli#510 review: this used to read
126
+ `os.environ["PATH"]` directly, 46 lines before `execute_slices`
127
+ assembled the slice's OWN `env` (the one the spawn below actually gets),
128
+ so a plan that pinned a different `PATH` in `command.env` resolved
129
+ against the PARENT's PATH and then spawned a DIFFERENT binary than the
130
+ one the check just approved -- the pre-fix `Command::new(&tool)` case
131
+ this whole issue exists to close, reintroduced one call up. Callers pass
132
+ the FULLY ASSEMBLED child env (post `assemble_slice_env`/
133
+ `with_venv_on_path`, or `flash_cmd`'s `prepend_path(os.environ,
134
+ venv_bin)`), never `os.environ` when the child gets something else -- a
135
+ plan that pins a PATH is asking for that PATH to be used, matching
136
+ pre-fix POSIX `Popen`, which always selected via `os.get_exec_path(env)`
137
+ (the spawn's OWN env), never the calling process's.
138
+
139
+ `searched` is populated on every return, including a hit: a missingTool
140
+ refusal that can only say "not found" is a support ticket; one that
141
+ names the literal PATH entries this walked is a fix the customer
142
+ applies themselves (tan-cli#510 acceptance)."""
143
+ if Path(tool).is_absolute():
144
+ if Path(tool).exists():
145
+ return ToolResolution(tool, f"`{tool}` (given as an absolute path)")
146
+ # tan-cli#510 review, minor: the old wording ("searched `<path>`
147
+ # (given as an absolute path)") echoed the same path back as if a
148
+ # search had run one -- nothing was searched, the path was just
149
+ # checked. Naming the miss plainly avoids the tautology.
150
+ return ToolResolution(None, f"`{tool}` -- that path does not exist")
151
+
152
+ if os.name != "nt":
153
+ # `os.get_exec_path(env)` mirrors what a POSIX `Popen(..., env=env)`
154
+ # itself consults to resolve a bare argv[0] (`os.defpath` when `env`
155
+ # carries no `PATH` at all) -- the same fallback `shutil.which`'s own
156
+ # `path=None` default would use from `os.environ`, reproduced here
157
+ # against `env` instead.
158
+ #
159
+ # **EMPTY entries are dropped first (tan-cli#567).** This is not
160
+ # tidiness: `PATH="$PATH:"`, `PATH=":$PATH"` and a `;;` on Windows are
161
+ # all routine, and an empty entry means "the current directory" to
162
+ # every POSIX PATH consumer. `shutil.which(tool, path=":/nope:")`
163
+ # joins `""` with the name and probes it RELATIVE TO CWD -- measured on
164
+ # this tree, `resolve_tool("cwdprobe", {"PATH": ":/nope:"})` answered
165
+ # `'cwdprobe'` (relative!) for a file that exists only in the process's
166
+ # own working directory. That is the exact hazard the hand-rolled walk
167
+ # exists to prevent, reachable on the BUILD path since tan-cli#510 and
168
+ # missed because the Windows branch below already filtered and the
169
+ # POSIX branch did not. `size_cmd`'s now-retired private copy had the
170
+ # filter; consolidating without it would have made three commands
171
+ # share one weaker lookup.
172
+ search_dirs = [d for d in os.get_exec_path(dict(env)) if d]
173
+ if not search_dirs:
174
+ return ToolResolution(None, "PATH is unset")
175
+ path = os.pathsep.join(search_dirs)
176
+ return ToolResolution(shutil.which(tool, path=path), f"PATH: {path}")
177
+
178
+ path = env.get("PATH")
179
+ if not path:
180
+ return ToolResolution(None, "PATH is unset")
181
+ names = windows_candidate_names(tool, env.get("PATHEXT", ".COM;.EXE;.BAT;.CMD"))
182
+ for directory in path.split(os.pathsep):
183
+ if not directory:
184
+ continue
185
+ for name in names:
186
+ # `os.path`, not `pathlib`, and the difference is MEASURED rather
187
+ # than stylistic (tan-cli#811). This is the only remaining site
188
+ # with the shape: a full miss probes every `%PATH%` entry x every
189
+ # `%PATHEXT%` suffix, which on the host that issue measured is 92
190
+ # dirs x 15 names = 1380 candidates, and a `Path` object is built
191
+ # and thrown away for each. Same loop, same candidate set, only
192
+ # the construction and stat swapped: 0.0389 -> 0.0177 s per miss,
193
+ # 2.2x. POSIX never reaches here (it returns via `shutil.which`
194
+ # above), so this is a Windows-only cost and a Windows-only fix.
195
+ #
196
+ # **This is not a pure cost swap, and the difference is the
197
+ # SHIPPED floor, not an edge case** (review of #874). On 3.14
198
+ # `Path.is_file()` IS `os.path.isfile` (`if follow_symlinks:
199
+ # return os.path.isfile(self)`), so the two are the same call.
200
+ # On 3.12 and 3.13 they are not: `Path.is_file()` re-raises any
201
+ # `OSError` outside `_IGNORED_ERRNOS`/`_IGNORED_WINERRORS`, while
202
+ # `genericpath.isfile` swallows every `OSError` and `ValueError`.
203
+ # `pyproject.toml` pins `requires-python = ">=3.12"` and the
204
+ # frozen binaries customers install are built on 3.12, so the
205
+ # divergence ships. Measured on this tree, one unreadable
206
+ # directory, same file:
207
+ #
208
+ # 3.12.14 Path.is_file() -> PermissionError errno=13
209
+ # os.path.isfile() -> False
210
+ # 3.13.15 Path.is_file() -> PermissionError errno=13
211
+ # os.path.isfile() -> False
212
+ # 3.14.7 both -> False
213
+ #
214
+ # Reachable on Windows: `ERROR_ACCESS_DENIED (5)` and
215
+ # `ERROR_SHARING_VIOLATION (32)` both map to `EACCES` in
216
+ # `PC/errmap.h`, so a `%PATH%` entry on a share whose credentials
217
+ # have expired, or an ACL-locked directory, used to abort the
218
+ # whole walk -- even when the tool sat in a LATER entry.
219
+ #
220
+ # Skipping it is the DELIBERATE choice, not a side effect. This
221
+ # loop carries no `try` of its own, so whatever the predicate
222
+ # raises escapes `resolve_tool` entirely; no call site guards
223
+ # it and `tan/cli.py` installs no top-level handler, so on
224
+ # 3.12/3.13 that `PermissionError` was a traceback where an
225
+ # envelope belongs. `tan/core/shapes.py` already states this
226
+ # repo's position for exactly this shape -- "a path with an
227
+ # embedded NUL or an unreadable parent must read as 'not an SDK
228
+ # root', never as a traceback where a refusal belongs" -- and a
229
+ # `%PATH%` entry tan cannot stat is the same question. An entry
230
+ # that is skipped is not silent: a tool found nowhere answers
231
+ # `resolved=None`, which `build_cmd` routes through
232
+ # `executionPolicy.missingTool` (`build_cmd._dispatch`, default
233
+ # skip) and names in the refusal. Pinned by
234
+ # `test_an_unreadable_path_entry_is_skipped_not_raised`.
235
+ #
236
+ # One asymmetry this leaves, small but real: the string PROBED is
237
+ # no longer the string RETURNED. `os.path.join` keeps a `.` or a
238
+ # duplicated separator the `%PATH%` entry carried where the
239
+ # `PureWindowsPath` return below collapses it, so
240
+ # `C:\\Windows\\.\\System32` probes 30 characters and returns 28.
241
+ # Against the 260-char `MAX_PATH` on a host without long paths
242
+ # enabled, a candidate within two characters of the limit can now
243
+ # miss where it previously hit.
244
+ if os.path.isfile(os.path.join(directory, name)):
245
+ # The RETURN is still built the pathlib way, once, on the
246
+ # winner -- deliberately NOT `os.path.join`'s own string.
247
+ # `os.path.join` preserves whatever separators the `%PATH%`
248
+ # entry carried (`os.path.join("C:/Windows/System32",
249
+ # "cmd.exe")` is `'C:/Windows/System32\\cmd.exe'`) where
250
+ # `str(PureWindowsPath(...) / ...)` normalises to
251
+ # `'C:\\Windows\\System32\\cmd.exe'`, and this string is
252
+ # customer-visible: `build_cmd` publishes it as
253
+ # `data.slices[].resolvedTool` (`build_cmd._slice_result`
254
+ # sets it unconditionally).
255
+ # tan-cli#811 proposed `os.path.normpath` on the return to
256
+ # close that gap; it does, but it ALSO collapses `..`, which
257
+ # pathlib does not, so a `%PATH%` entry written relative to a
258
+ # parent would come back rewritten. Rebuilding the winner
259
+ # with the original expression is byte-identical instead of
260
+ # merely equivalent, and costs one path object per HIT, never
261
+ # per candidate.
262
+ #
263
+ # `PureWindowsPath`, not `Path`, and that is a TESTABILITY
264
+ # fix rather than a behaviour one (review of #874). The two
265
+ # produce the same string on Windows by inheritance, not by
266
+ # coincidence: `WindowsPath.__mro__` contains
267
+ # `PureWindowsPath`, `__str__` and `__truediv__` are the SAME
268
+ # objects on both, and both carry the frozen `ntpath` parser
269
+ # (verified on 3.12.14 and 3.14.7). But `Path` here made the
270
+ # hit path unreachable from a POSIX test -- `WindowsPath /
271
+ # str` refuses to build -- which is how this walk's only
272
+ # regression test decayed into grepping its own source. This
273
+ # module already uses `PureWindowsPath` for the same reason
274
+ # in `windows_candidate_names` below.
275
+ return ToolResolution(str(PureWindowsPath(directory) / name), f"PATH: {path}")
276
+ return ToolResolution(None, f"PATH: {path}")
277
+
278
+
279
+ def windows_candidate_names(tool: str, pathext: str) -> list[str]:
280
+ """The file names the `%PATH%` walk above may consider for the bare
281
+ identity `tool`: `tool` verbatim once it already carries an extension,
282
+ and otherwise `tool` + each `%PATHEXT%` suffix -- **never the bare,
283
+ extensionless name itself.**
284
+
285
+ Split out as a pure function so the Windows candidate set can be
286
+ unit-tested from any host, mirroring the Rust oracle, which splits its own
287
+ search core out for the same reason and says so
288
+ (`crates/tan-cli/src/util.rs::find_on_path`, "Pure search core split out
289
+ of `windows_path_lookup` purely for unit testing").
290
+
291
+ What this said before tan-cli#811 -- that `resolve_tool`'s Windows branch
292
+ is unreachable from POSIX because `pathlib.Path` "dispatches on `os.name`
293
+ at construction, so patching it raises" -- was wrong in both halves, and
294
+ three tests here were built on the false version. Measured on 3.12.14 and
295
+ 3.14.7: `Path("git")` under a patched `os.name` CONSTRUCTS a
296
+ `WindowsPath` (`Path.__new__` reaches `object.__new__(cls)`, bypassing
297
+ the raising subclass `__new__`), and the lexical and stat methods then do
298
+ not raise, they LIE -- `Path("/usr/bin/git").is_absolute()` is `False`.
299
+ Where a refusal does happen (`WindowsPath / str`, or `WindowsPath(...)`
300
+ directly) it is a plain `NotImplementedError` on the SHIPPED floor:
301
+ `pathlib` has no `UnsupportedOperation` attribute at all on 3.12, which
302
+ 3.13 renamed it into as a `NotImplementedError` subclass.
303
+
304
+ So since tan-cli#811 the walk is DELIBERATELY reachable from POSIX and is
305
+ driven there -- the miss loop probes through `os.path`, the hit spells
306
+ with `PureWindowsPath` -- because while it was not, this walk's only
307
+ regression test decayed into grepping the module's own source.
308
+
309
+ A patched-`os.name` test proves less than it runs: `os.path` stays
310
+ `posixpath`, so the probe joins with `/` against a real POSIX filesystem
311
+ while the return spells with `\\`. Such a test pins candidate ORDER, the
312
+ skip-don't-raise decision and the returned SPELLING -- not Windows path
313
+ syntax on the probe, which is this function's own job.
314
+
315
+ Excluding the bare name is the one deliberate Windows-arm behaviour
316
+ change tan-cli#567 makes; this module's own docstring carries the whole
317
+ justification under "The Windows candidate set", and
318
+ `test_bare_argv0_spawn.py::test_the_windows_walk_never_considers_the_bare
319
+ _extensionless_name` pins it.
320
+ """
321
+ if PureWindowsPath(tool).suffix:
322
+ return [tool]
323
+ # `;` literally, not `os.pathsep`: `%PATHEXT%` is a Windows-only variable
324
+ # and is always `;`-separated (the oracle splits it on `';'` too). They are
325
+ # the same character on the platform this runs on -- spelling it makes the
326
+ # function answer the Windows question from any host, which is the point of
327
+ # extracting it.
328
+ return [tool + ext for ext in pathext.split(";") if ext]
329
+
330
+
331
+ def resolve_program_positions(
332
+ argv: list[str], env: Mapping[str, str], separator: str | None = None
333
+ ) -> tuple[list[str], str | None]:
334
+ """Rewrite every PROGRAM position in `argv` -- `argv[0]`, plus the token
335
+ right after each `separator` (a `"|"` pipeline token, when one is given) --
336
+ to the absolute path [`resolve_tool`] found for it against `env`, the SAME
337
+ environment the spawn will hand the child.
338
+
339
+ Returns `(argv, unresolved)`. `unresolved` names the FIRST program position
340
+ that resolved to nothing, and is `None` when every one of them resolved.
341
+ Arguments are never touched.
342
+
343
+ A program that ALREADY carries a path separator is left exactly as it is
344
+ and never counts as unresolved: it is not a PATH identity, so there is no
345
+ PATH lookup to harden. That covers the absolute paths a caller resolved
346
+ itself (`flash_cmd._programs_resolved_in_venv`'s venv rewrite, which
347
+ already probed the file) without re-searching them, and it keeps this
348
+ agreeing with `is_rust_absolute` on the rooted-but-driveless Windows form
349
+ that `Path.is_absolute` and `os.path.isabs` disagree about across
350
+ interpreter versions (`tan.core.flash_plan`'s own convention).
351
+
352
+ **A bare program that does not resolve is left BARE, and the caller
353
+ decides.** This function never invents a path. Its callers on the write
354
+ path refuse the spawn outright rather than pass a bare name to
355
+ `CreateProcess`, because "not on PATH and not in the venv" leaves the
356
+ current directory as the only remaining supplier -- which is the whole
357
+ hazard. Pure: no IO beyond the `is_file`/`exists` probes `resolve_tool`
358
+ already does.
359
+ """
360
+ out: list[str] = []
361
+ unresolved: str | None = None
362
+ is_program = True
363
+ for arg in argv:
364
+ if is_program and not _has_path_separator(arg):
365
+ resolution = resolve_tool(arg, env)
366
+ if resolution.resolved is None:
367
+ if unresolved is None:
368
+ unresolved = arg
369
+ out.append(arg)
370
+ else:
371
+ out.append(resolution.resolved)
372
+ else:
373
+ out.append(arg)
374
+ is_program = separator is not None and arg == separator
375
+ return out, unresolved
376
+
377
+
378
+ def _has_path_separator(arg: str) -> bool:
379
+ """Whether `arg` names a location rather than a PATH identity. `os.altsep`
380
+ is checked too, so a forward-slashed path on Windows -- which the OS
381
+ accepts and half this repo's fixtures write -- is not mistaken for a bare
382
+ command name."""
383
+ return os.sep in arg or (os.altsep is not None and os.altsep in arg)