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/cli.py ADDED
@@ -0,0 +1,939 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """The `tan` command-line surface: the Typer app, the root callback, and the
3
+ `--format json` error path that wraps Click's own dispatch.
4
+
5
+ Defines ``main``, but no longer owns the PROCESS boundary: ``pyproject.toml``'s
6
+ ``[project.scripts]`` names ``tan.__main__:main``, and ``__main__.py`` wraps
7
+ this ``main`` to swallow a closed stdout (``tan generate --help | head``) as a
8
+ quiet success instead of a traceback. Anything that must happen for EVERY
9
+ invocation regardless of subcommand belongs there, not here.
10
+
11
+ Commands register in ``tan.command_table.COMMAND_TABLE`` with a STATIC,
12
+ function-local import -- see ``tan.commands.__init__`` for why an
13
+ importlib/pkgutil registry is a trap: it works from source and fails inside a
14
+ PyInstaller ``--onefile`` binary, which is how tan actually ships. ``app`` is a
15
+ ``LazyTyper`` that refuses ``app.command(...)``: ``COMMAND_TABLE`` is the one
16
+ place to add a command, and the single table everything else here derives from
17
+ (``_SUBCOMMAND_NAMES``, and the pre-/post-subcommand ``--format`` cases in
18
+ ``tests/commands/test_cli_global_flags.py``).
19
+ """
20
+ import io
21
+ import sys
22
+
23
+ import typer
24
+ from typer.core import TyperCommand
25
+
26
+ from tan.core.global_flags import RELOCATABLE_FLAG_ARITY
27
+ from tan.envelope import (
28
+ Envelope,
29
+ Issue,
30
+ Project,
31
+ emit,
32
+ envelope_emitted,
33
+ envelope_emitted_exit_code,
34
+ )
35
+ from tan.exit_codes import ExitCode
36
+ from tan.lazy_typer import COMMAND_NAMES, LazyTyper
37
+ from tan.output_format import declared_formats, invalid_format_message
38
+ from tan.version import TAN_VERSION
39
+
40
+
41
+ #: The `--format` a DISPATCHED subcommand actually resolved to, as a bool, or
42
+ #: `None` when NO command body ran this process (`main()` resets it per run).
43
+ #:
44
+ #: This is the missing half of Rust's own structure (tan-cli#546/#491 defect 3).
45
+ #: `crates/tan-cli/src/main.rs` decides the channel like this:
46
+ #:
47
+ #: match Cli::try_parse() {
48
+ #: Ok(cli) => run(cli), // channel = cli.format
49
+ #: Err(e) => if wants_json(argv) { ... } // channel = the textual scan
50
+ #: }
51
+ #:
52
+ #: -- so the naive `wants_json` scan is consulted ONLY once clap has already
53
+ #: FAILED, and can never affect a run that parsed. The port promoted the same
54
+ #: scan to a process-wide switch, which is what let a `--format json` that is
55
+ #: not tan's own flag (forwarded past `--` to `west alp-quality`, or sitting in
56
+ #: another option's value slot) replace a text-mode command's real, coded answer
57
+ #: with an unrequested `command:"cli"` / `cli.parse-error` document on stdout.
58
+ #:
59
+ #: Two earlier attempts tried to answer the same question by making the argv
60
+ #: SCAN smarter (an arity walk, then a clap-style hyphen guard). Both failed,
61
+ #: and a third textual shape cannot work either -- MEASURED against the oracle,
62
+ #: which answers the two structurally identical argvs differently:
63
+ #:
64
+ #: tan quality -- --format json oracle: TEXT (0 bytes on stdout)
65
+ #: tan build -- --format json oracle: JSON (`cli.parse-error`)
66
+ #:
67
+ #: The only thing separating them is whether the parser ACCEPTED the argv
68
+ #: (`quality` forwards trailing args, `build` refuses them), which no scan of
69
+ #: argv alone can know. So the scan stays exactly as it is -- a faithful port of
70
+ #: Rust's `wants_json`, used only where Rust uses it -- and the parse OUTCOME is
71
+ #: recorded here instead.
72
+ _dispatched_json_mode: bool | None = None
73
+
74
+
75
+ class _DispatchedCommand(TyperCommand):
76
+ """A `TyperCommand` that records the `--format` its own parse resolved.
77
+
78
+ `Command.invoke` is called by Click only after `cmd.make_context(...)` has
79
+ already succeeded, so reaching it means the FULL parse (root's flags, the
80
+ subcommand name, and the subcommand's own options) was accepted and this
81
+ command's body is about to run -- exactly Rust's `Ok(cli)` arm, and exactly
82
+ the boundary `main()` cannot otherwise see from the outside.
83
+
84
+ The `--format` parameter is found by its OPTION STRING rather than by the
85
+ `output_format` parameter name every command happens to use today: the name
86
+ is a convention 32 modules share, the option string is the contract.
87
+ """
88
+
89
+ def invoke(self, ctx):
90
+ global _dispatched_json_mode
91
+ param = next((p for p in ctx.command.params if "--format" in p.opts), None)
92
+ value = ctx.params.get(param.name) if param is not None else None
93
+ # `OutputFormat.JSON` (an enum), the plain string a `ValidateOutputFormat`
94
+ # or a future non-enum choice would give, or `None` for the commands
95
+ # whose `--format` defaults to `None` rather than `TEXT` -- all three
96
+ # collapse to "is this the envelope channel".
97
+ _dispatched_json_mode = getattr(value, "value", value) == "json"
98
+ return super().invoke(ctx)
99
+
100
+
101
+ app = LazyTyper(add_completion=False, command_cls=_DispatchedCommand)
102
+
103
+
104
+ # Registered through `tan.command_table.COMMAND_TABLE`, whose loaders are plain
105
+ # STATIC `from tan.commands.x_cmd import x` statements inside functions --
106
+ # PyInstaller follows the static import graph only, so a pkgutil/importlib
107
+ # auto-registry works from source and produces a frozen `tan` that cannot find
108
+ # its own commands (see `tan.commands.__init__`), while a function-local import
109
+ # is still seen. Nothing here imports a command module: `LazyTyper` runs a
110
+ # loader only when Click looks that command up, so `tan --version` and
111
+ # `tan <cmd> --help` skip the other ~30 modules (tan-cli#1505). Registering in
112
+ # the table rather than with a decorator in the command module keeps
113
+ # `tan.commands.*` free of any `tan.cli` import, which would be a cycle. Each
114
+ # row carries a `rich_help_panel` that groups commands into six titled panels
115
+ # on `--help`; a new command gains grouping with the same row that registers it.
116
+ # Every command dispatches through `_DispatchedCommand` (set as the Typer's
117
+ # `command_cls`) rather than threading `cls=` through each row.
118
+
119
+
120
+ #: Every registered subcommand name, DERIVED from `tan.command_table.COMMAND_TABLE`
121
+ #: rather than retyped beside it (tan-cli#378). Used only to find the argv
122
+ #: BOUNDARY `_reorder_global_flags` moves a leading global flag across; it is
123
+ #: never itself treated as a flag. Hand-kept, this was a second place to forget
124
+ #: a command -- and forgetting one is silent: a name missing from the set is not
125
+ #: recognised as the boundary, so the whole rewrite aborts and every leading
126
+ #: global flag on that command goes back to being a Click "No such option".
127
+ #: Reading names off the table costs no command import (tan-cli#1505).
128
+ _SUBCOMMAND_NAMES = frozenset(COMMAND_NAMES)
129
+
130
+ #: clap's `GlobalArgs` (`crates/tan-cli/src/cli.rs` lines 24-73) PLUS
131
+ #: `--format`: every field there is `#[arg(long, global = true, ...)]`, so clap
132
+ #: accepts each on EITHER side of the subcommand name, and `--format` is one of
133
+ #: them. `--version` is not -- it lives on `Cli` directly in clap, not
134
+ #: `GlobalArgs`, and is root-only on both sides already.
135
+ #: Value: the flag's arity (1 = takes a value, 0 = boolean).
136
+ #:
137
+ #: Imported from `tan.core.global_flags` rather than hand-copied a second
138
+ #: time (tan-cli#261): that module is also what
139
+ #: `tan.core.global_flags.accept_global_flags` reads to decide which flags a
140
+ #: command is missing, so this reorder table and the per-command injection
141
+ #: list cannot drift apart the way two independent hand-written copies of
142
+ #: clap's `GlobalArgs` field list eventually would. `--format` is in the
143
+ #: relocation table but NOT the injection list -- see `RELOCATABLE_FLAG_ARITY`
144
+ #: there for why the two must stay separate.
145
+ _RELOCATABLE_FLAG_ARITY: dict[str, int] = RELOCATABLE_FLAG_ARITY
146
+
147
+ #: The oracle's global `--format` only ever had two values (measured:
148
+ #: `target/debug/tan.exe --format sarif validate` and `... --format bogus
149
+ #: new-som --sku FOO` both answer `error: invalid value '<x>' for '--format
150
+ #: <FORMAT>' [possible values: text, json]` at exit 2, in BOTH argv
151
+ #: positions) -- but this port's global `--format` is wider than that
152
+ #: (tan-cli#403): `_format_callback` accepts `_every_declared_format()`, the
153
+ #: union over every registered command's own `--format`, not a hard-coded
154
+ #: pair. `_reorder_global_flags` has to relocate that SAME domain, via the
155
+ #: SAME function, or a value outside the oracle's two but inside a real
156
+ #: command's own domain (`diagnostic-v1`/`sarif`, `validate`'s) collapses the
157
+ #: rewrite and drops the subcommand entirely instead of relocating past it
158
+ #: (tan-cli#433) -- a second, hand-kept copy of this set is exactly the drift
159
+ #: that produced #433 in the first place.
160
+
161
+
162
+ def _reorder_global_flags(argv: list[str]) -> list[str]:
163
+ """Move a leading GLOBAL flag (`_RELOCATABLE_FLAG_ARITY`) from before the
164
+ subcommand name to immediately after it, where a command that implements
165
+ it already has its own local option declared (see e.g. `clean_cmd.clean`'s
166
+ trailing `--quiet`/`--ci`/`--target`/... parameters) -- Click only reads
167
+ options declared on the GROUP callback (`root`, below) for anything
168
+ appearing before the subcommand name, and `root` does not declare these,
169
+ so today they are a hard parse error there.
170
+ Concretely: `alp-sdk-vscode/src/west.ts`'s `alpBuild` invokes `tan
171
+ --project <app> build`, and `alpCli/vscodeAdapter.ts`'s `withSdkRoot`
172
+ prepends `--sdk-root <path>` ahead of the subcommand for nearly every
173
+ command the extension runs (`runAlpCommand`/`runAlpInTerminal`).
174
+
175
+ A pure `list[str] -> list[str]` argv rewrite, run before Typer/Click ever
176
+ sees it, so no per-command code has to change to gain the pre-subcommand
177
+ position -- only the POSITION moves. A command that does not implement a
178
+ given flag at all keeps failing exactly as it does in the (already
179
+ correct, already tested) post-subcommand position -- e.g. `tan build
180
+ --sdk-root x --bogus` and `tan --sdk-root x build --bogus` both still
181
+ fail on `--bogus`; this never invents support a command never had, and
182
+ never swallows an unrecognised flag silently.
183
+
184
+ `--format` is relocated by exactly this mechanism too (tan-cli#378), and
185
+ that is the WHOLE of how it is global now. It used to be excepted here --
186
+ left sitting ahead of the subcommand for `root`'s own `--format` to catch
187
+ and stash on `ctx.obj`, with a hand-written set of commands allowed to
188
+ read it back. That set reached 12 of the 32 registered commands and
189
+ stopped; the other 20 (`tan --format json doctor`, `... model list`,
190
+ `... sdk current`, `... validate`, ...) were refused at rc=2 with a
191
+ `cli.parse-error` envelope naming `command: "cli"` -- the wrong command
192
+ for a machine caller to key off, on argv the oracle accepts. Relocation
193
+ needs no such set because it does not depend on a command opting in:
194
+ every registered command declares AND reads its own `--format` (the
195
+ position every golden already uses, and where each command's own
196
+ "'x' (choose from 'text', 'json')" validation lives), so moving the token
197
+ there hands it to a parameter that acts on it. A command added later is
198
+ covered by the `COMMAND_TABLE` row that registers it, with nothing
199
+ else to remember -- pinned by
200
+ `tests/commands/test_cli_global_flags.py`, which derives its cases from
201
+ that same registration table.
202
+
203
+ `--format`'s relocatable domain is `_every_declared_format()` -- the union
204
+ over every registered command's own `--format`, not just the oracle's
205
+ `text`/`json` pair (tan-cli#403) -- because `root`'s own eager
206
+ `_format_callback` accepts that identical union; the two have to read the
207
+ SAME set or a value `_format_callback` would happily accept gets treated
208
+ here as not-the-flag-at-all and collapses the rewrite instead of
209
+ relocating past the subcommand name (tan-cli#433). A value outside even
210
+ that union -- `bogus` -- still is not this flag, and the rewrite still
211
+ collapses to just that `--format <value>` pair so `root` refuses it at
212
+ parse time, before any command resolves -- see the `--format` branch below
213
+ for why leaving the rest of argv in place does not reach the refusal.
214
+
215
+ Deliberately conservative otherwise: any OTHER token before the first
216
+ subcommand name that is not a recognised global flag (or that flag's
217
+ value) — `--help`, `--version`, or a bare positional — aborts the rewrite
218
+ and returns `argv` untouched, so every existing argv shape (a normal `tan
219
+ build ...` with zero leading tokens is a no-op by construction; `--version`,
220
+ a bad command, a bare invocation, all of which have no subcommand token to
221
+ move anything after) sees the exact argv it always has.
222
+ """
223
+ moved: list[str] = []
224
+ i = 0
225
+ n = len(argv)
226
+ while i < n:
227
+ token = argv[i]
228
+ if token in _SUBCOMMAND_NAMES:
229
+ return [token, *moved, *argv[i + 1 :]]
230
+ name = token.split("=", 1)[0]
231
+ arity = _RELOCATABLE_FLAG_ARITY.get(name)
232
+ if arity is None:
233
+ return argv # not a recognised global flag and not the subcommand
234
+ if name == "--format":
235
+ # The relocatable domain is `_every_declared_format()`, not a
236
+ # hard-coded `text`/`json` pair (tan-cli#433, a regression of
237
+ # tan-cli#403): `_format_callback` already accepts the union over
238
+ # every registered command's `--format`, including `validate`'s
239
+ # `diagnostic-v1`/`sarif`, so this rewrite has to relocate that
240
+ # SAME set or it refuses a value `root` itself would have
241
+ # accepted, collapsing the argv and dropping the subcommand along
242
+ # with it -- exactly #433's `tan --format diagnostic-v1 validate
243
+ # --offline`, which lost `validate` entirely and answered "a
244
+ # command is required" instead of the diagnostic-v1 document `tan
245
+ # validate --offline --format diagnostic-v1` produces.
246
+ #
247
+ # A value outside even that union is not this flag at all, and the
248
+ # oracle refuses such a VALUE at PARSE time, before the command
249
+ # body runs -- measured: `tan --format bogus new-som --sku FOO`
250
+ # rejects the value and does no work, while a rewrite that let the
251
+ # value through made it something only `new-som`'s own body
252
+ # validates, and `new-som` WRITES metadata files. The rewrite
253
+ # therefore collapses to exactly the two tokens that reproduce
254
+ # that refusal through `root`'s own `_format_callback`, dropping
255
+ # the rest of argv -- see the `return` below for why leaving argv
256
+ # alone does not work.
257
+ if "=" in token:
258
+ value = token.split("=", 1)[1]
259
+ else:
260
+ value = argv[i + 1] if i + 1 < n else None
261
+ if value not in _every_declared_format():
262
+ # Just returning `argv` here (the first cut of tan-cli#378)
263
+ # does NOT reach that refusal, for two measured reasons.
264
+ #
265
+ # (1) `root`'s `--format` is a plain, non-`multiple` Click
266
+ # option, so Click's parser overwrites `state.opts` on each
267
+ # occurrence and only ever type-casts the LAST one
268
+ # (`click.parser._match_long_opt`) -- `_format_callback` is
269
+ # never handed the bad value at all when a second `--format`
270
+ # follows. Measured: `tan --format bogus --format json new-som
271
+ # --sku FOO` reached `new-som`'s BODY (its SDK-root preflight
272
+ # message printed), where the oracle answers `invalid value
273
+ # 'bogus'` having run nothing, and where a resolvable
274
+ # `--sdk-root` means real metadata writes.
275
+ #
276
+ # (2) Returning the untouched argv also strands every global
277
+ # flag already scanned ahead of this one back in the
278
+ # pre-subcommand position `root` does not declare: `tan
279
+ # --verbose --format bogus validate` answered `No such option:
280
+ # --verbose` -- naming a flag this port supports, for an argv
281
+ # whose actual fault is the `--format` value.
282
+ #
283
+ # Collapsing to `--format <bad-value>` alone fixes both: it is
284
+ # the only argv that makes `root` refuse THIS value, at parse,
285
+ # before any command resolves. Everything dropped is either a
286
+ # global flag (accepted, never read on a refused invocation) or
287
+ # the subcommand the oracle likewise never runs. The `=`
288
+ # spelling collapses to its single token; a trailing `--format`
289
+ # with no value at all collapses to just the flag, which Click
290
+ # reports as "Option '--format' requires an argument." (exit 2,
291
+ # matching clap's own "a value is required" refusal).
292
+ return argv[i : i + (1 if "=" in token else 2)]
293
+ if "=" in token or arity == 0:
294
+ moved.append(token)
295
+ i += 1
296
+ continue
297
+ if i + 1 >= n:
298
+ return argv # "--sdk-root" with no value: let Click report it natively
299
+ moved.extend((token, argv[i + 1]))
300
+ i += 2
301
+ return argv # no subcommand token ever found
302
+
303
+
304
+ def _value_taking_options(command: str | None) -> frozenset[str]:
305
+ """Every option STRING that swallows the next argv token as its value --
306
+ `root`'s own, plus `command`'s when argv names one.
307
+
308
+ Read off Click's real parameter objects rather than a table in this
309
+ module: the options that matter here are per-command (`scaffold --name`,
310
+ `explain --template`, `init --destination`), so no table here could know
311
+ them, and a table that tried would be stale the first time a command grew
312
+ an option. `--help`/`--version` and every other boolean are excluded by
313
+ `is_flag`; `click.Argument`s are excluded by the leading-dash test (their
314
+ `opts` carry the parameter NAME, not a flag).
315
+ """
316
+ group = app.lazy_group()
317
+ params = list(group.params)
318
+ subcommand = group.commands.get(command) if command is not None else None
319
+ if subcommand is not None:
320
+ params += list(subcommand.params)
321
+ return frozenset(
322
+ opt
323
+ for param in params
324
+ for opt in (*param.opts, *param.secondary_opts)
325
+ if opt.startswith("-") and not getattr(param, "is_flag", False) and param.nargs == 1
326
+ )
327
+
328
+
329
+ def _wants_help(argv: list[str]) -> bool:
330
+ """Whether `--help` appears in an OPTION position -- not as some other
331
+ option's VALUE (tan-cli#394).
332
+
333
+ A plain `"--help" in argv` was wrong in a way `--help`'s eagerness hides:
334
+ Click's parser takes the next token as a value-carrying option's argument
335
+ WITHOUT checking whether it looks like an option
336
+ (`click.parser._get_value_from_state` pops `rargs[0]` unconditionally), so
337
+ a `--help` consumed that way NEVER reaches the eager help callback. The
338
+ pre-scan believed it had; `main()` then routed the whole argv through
339
+ `_emit_help_envelope`, which runs the command under `CliRunner` and
340
+ reports its captured output as one `command: "cli"` envelope. Measured:
341
+ `tan scaffold --format json --name --help --destination out` wrote three
342
+ real files and answered `{"command":"cli","ok":true,...,"data":{"message":
343
+ "<the escaped scaffold envelope>"}}` -- so a consumer reading
344
+ `data.written` saw `[]` for a run that wrote, and `tan explain --format
345
+ json --template --help` re-labelled a real `explain.template-unknown`
346
+ refusal as `cli.parse-error`. Text mode reported the truth in both cases;
347
+ only the JSON channel lied.
348
+
349
+ Walking argv with the real arity table instead means the two channels
350
+ agree: a `--help` in value position is left to the command, which then
351
+ emits its OWN envelope (the same one text mode describes), and a `--help`
352
+ in option position still short-circuits here.
353
+ """
354
+ takes_value = _value_taking_options(
355
+ next((token for token in argv if token in _SUBCOMMAND_NAMES), None)
356
+ )
357
+ i = 0
358
+ while i < len(argv):
359
+ token = argv[i]
360
+ # `--` ends option parsing for Click exactly as it does for clap, so a
361
+ # `--help` after it is a positional argument and the eager help callback
362
+ # never fires. Measured against the oracle: `tan explain -- --help`
363
+ # exits 2 with a refusal, not help.
364
+ if token == "--":
365
+ return False
366
+ if token == "--help":
367
+ return True
368
+ # `--opt=value` carries its value inline, so it consumes one token; a
369
+ # bare value-taking `--opt` eats the token after it, whatever it says.
370
+ i += 2 if token in takes_value else 1
371
+ return False
372
+
373
+
374
+ def _emit_help_envelope(argv: list[str]) -> int:
375
+ """Under `--format json`, `--help` must still land as ONE JSON envelope on
376
+ stdout -- mirroring Rust's `emit_parse_error` path for clap's DisplayHelp
377
+ error kind (`main.rs`, `json_mode_help_yields_zero_exit_and_no_issue`:
378
+ exit 0, `issues: []`, the rendered help as `data.message`). Click's own
379
+ `--help` handling prints straight to stdout and calls `ctx.exit(0)` before
380
+ a command (or even `root`) ever runs, bypassing `emit()` entirely, so it
381
+ has to be intercepted here instead -- `CliRunner` drives the exact same
382
+ Click command and captures what it would have printed as a string,
383
+ without ever touching the real stdout.
384
+
385
+ Not help-specific in what it reads back: `result.exit_code`/`result.output`
386
+ cover the rare case where argv makes Click reject the invocation before
387
+ ever reaching the eager `--help` callback, the same way Rust's generic
388
+ `err.exit_code()`/`err.render()` do for ANY clap parse outcome, help
389
+ included.
390
+
391
+ Returns the exit code the ENVELOPE just printed reports, so the caller can
392
+ `sys.exit` it: `tan --format json badcmd --help` renders help for an
393
+ unknown command, which Click (and the oracle) both exit 2 for, and a
394
+ process exit of 0 there would contradict the very envelope on stdout.
395
+
396
+ tan-cli#810: `CliRunner` is imported HERE, not at module scope. It is the
397
+ only use of `click.testing` in the package -- a TEST helper reached on one
398
+ production path -- and `cli.py` is imported by every `tan` invocation,
399
+ `tan --version` included, for a branch almost none of them take.
400
+ """
401
+ from click.testing import CliRunner # noqa: PLC0415
402
+
403
+ result = CliRunner().invoke(app.lazy_group(), argv, prog_name="tan")
404
+ message = result.output.strip()
405
+ code = result.exit_code
406
+ issues = [] if code == 0 else [Issue("cli.parse-error", "error", message)]
407
+ emit(
408
+ Envelope(
409
+ "cli",
410
+ Project(root=None, board_yaml=None),
411
+ {"message": message},
412
+ issues,
413
+ code,
414
+ )
415
+ )
416
+ return code
417
+
418
+
419
+ def _format_callback(ctx: typer.Context, value: str | None) -> str | None:
420
+ """Validates `--format`'s value the moment Click parses it. Marked
421
+ `is_eager=True` on the option below, alongside `--version`
422
+ (`_version_callback`), so the two race on ARGV POSITION rather than on
423
+ `root`'s declaration order -- Click sorts eager params by the order they
424
+ actually appeared on the command line, not by where they're declared
425
+ (`click.core.iter_params_for_processing`). Verified against the oracle
426
+ (`target/debug/tan.exe`): `tan --format bogus --version` exits 2 on the
427
+ bad value without ever reaching `--version` (`--format` comes first in
428
+ argv); `tan --version --format bogus` instead prints the version and
429
+ exits 0, never validating the value that comes after it (`--version` wins
430
+ the race and exits before `--format` is ever processed). Without
431
+ `is_eager=True` here, `--version`'s own eager callback would ALWAYS run
432
+ first regardless of position -- eager beats non-eager unconditionally --
433
+ which would have broken the already-tested `--format json --version` /
434
+ `--format=json --version` cases (`test_version_under_format_json_is_an_
435
+ envelope_not_a_bare_line`): `--version`'s callback would fire before
436
+ `--format`'s value had even been parsed.
437
+
438
+ clap validates `--format`'s VALUE eagerly too -- measured: `tan --format
439
+ bogus`, `tan --format bogus --version`, and `tan --format "" build` (an
440
+ empty value counts as invalid: clap says "a value is required for
441
+ '--format <FORMAT>' but none was supplied") all exit 2 on the value
442
+ itself. `ctx.fail()` gives the same Click UsageError shape (exit 2) every
443
+ other CLI mistake here already gets.
444
+
445
+ Reaches the argv shapes where `--format` is still parsed by `root` since
446
+ tan-cli#378 relocated the pre-subcommand position: no subcommand at all
447
+ (bare `tan --format bogus`, `tan --format bogus --version`), a rewrite
448
+ `_reorder_global_flags` aborted on some earlier unrecognised token (which
449
+ is itself a usage error) -- and, deliberately, EVERY pre-subcommand
450
+ `--format` whose value is outside `_ROOT_FORMAT_VALUES`, which that
451
+ function leaves in place precisely so this refusal is what a caller gets.
452
+ That is the oracle's own ordering: it rejects the value before the
453
+ subcommand runs, so `tan --format bogus new-som --sku FOO` writes nothing.
454
+ With `text`/`json` and a subcommand named, the value lands on that
455
+ command's own `--format` instead, whose identically-shaped "'x' (choose
456
+ from 'text', 'json')" `BadParameter` -- the first statement in every
457
+ command body, before any work -- refuses the post-subcommand position at
458
+ the same exit 2.
459
+ """
460
+ if value is None:
461
+ return value
462
+ # The domain here is the UNION over every registered command, not the
463
+ # two-value list this used to hard-code (tan-cli#403). It has to be: the
464
+ # callback is EAGER and fires before `ctx.invoked_subcommand` is known, so
465
+ # a `text`/`json` domain refused `tan --format diagnostic-v1 validate
466
+ # --offline` at exit 2 while the post-subcommand spelling of the same run
467
+ # emitted the document. `validate` really does declare four formats
468
+ # (`ValidateOutputFormat`), and the two spellings are meant to be one flag.
469
+ #
470
+ # Eagerness itself stays, and is not negotiable: the docstring above pins
471
+ # measured oracle behaviour where `tan --format bogus --version` must exit
472
+ # 2 BEFORE `--version` runs. Moving the whole check into `root`'s body
473
+ # would hand that race to `--version`.
474
+ #
475
+ # This is only the OUTER bound. `root` narrows to the invoked subcommand's
476
+ # own list once it knows which one that is.
477
+ allowed = _every_declared_format()
478
+ if value not in allowed:
479
+ ctx.fail(invalid_format_message(value, allowed))
480
+ return value
481
+
482
+
483
+ def _every_declared_format() -> tuple[str, ...]:
484
+ """Every `--format` value ANY registered command accepts, first-seen order.
485
+
486
+ Derived from the live command tree rather than listed. A hand-kept union
487
+ would be a second copy of the same fact, and a command that gained a
488
+ fourth format would then be refused here by a stale copy -- which is the
489
+ exact shape of the bug this replaced.
490
+ """
491
+ seen: dict[str, None] = {}
492
+ group = app.lazy_group()
493
+ for name in sorted(getattr(group, "commands", {})):
494
+ for value in declared_formats(group.commands[name]):
495
+ seen.setdefault(value, None)
496
+ # `or` guards the degenerate case only: a tree with no `--format` anywhere
497
+ # would otherwise refuse every value, including the two that always worked.
498
+ return tuple(seen) or ("text", "json")
499
+
500
+
501
+ def _version_callback(ctx: typer.Context, value: bool) -> bool:
502
+ """Genuinely eager `--version`, via Typer's own `is_eager=True` +
503
+ `callback=` mechanism (the option below; the same idiom
504
+ `click.version_option()` uses) -- not a hand-rolled `sys.exit` scattered
505
+ through `root`'s body.
506
+
507
+ tan-cli#326: a bare `return` from inside `root`'s function BODY does not
508
+ stop Click's own group dispatch. `click.core.MultiCommand.invoke`
509
+ resolves the subcommand and calls the group callback's body BEFORE it
510
+ invokes the subcommand, so a body that just `return`s (the pre-fix shape)
511
+ falls straight through to the subcommand running anyway -- `tan --version
512
+ init --template zephyr-app --destination <dir>` printed the version AND
513
+ created the project. Raising `typer.Exit` from an EAGER option's own
514
+ callback instead stops the run during argument PARSING itself
515
+ (`Command.parse_args`, called from `make_context`), which happens before
516
+ `MultiCommand.invoke` -- and therefore before subcommand resolution -- is
517
+ ever reached; `Command.main` wraps `make_context` and `invoke` in the SAME
518
+ try/except Exit, so this is caught and converted to a real process exit
519
+ exactly the same way a `typer.Exit` raised from the body would be
520
+ (verified empirically: a `CliRunner` probe with a dummy subcommand behind
521
+ two eager options confirms the subcommand never runs when the earlier one
522
+ raises).
523
+
524
+ `ctx.resilient_parsing` guards the same case Click's own `version_option`
525
+ guards: shell-completion parsing, which must not have side effects (not
526
+ reachable here today -- `add_completion=False` -- but the guard is the
527
+ documented idiom, kept for when that changes).
528
+
529
+ The envelope-vs-plain-text choice below is a raw scan of the real argv
530
+ (`_wants_json`, the SAME textual scan `main()` uses to route `--help`),
531
+ not `ctx.params.get("output_format")` -- deliberately, and NOT what a
532
+ first pass at this fix reached for. `--format`'s own callback is eager
533
+ too, but Click only races two eager options against each other while
534
+ BOTH are options of the SAME command (`root`); `tan --version sdk current
535
+ --format json` puts `--format json` on the OTHER side of the subcommand
536
+ boundary entirely -- Click hands `root` only `["--version"]` and leaves
537
+ `["sdk", "current", "--format", "json"]` as protected args for `sdk`'s own
538
+ parser, so `root`'s `output_format` parameter is `None` there regardless
539
+ of processing order; `ctx.params` genuinely never has the answer. Yet the
540
+ oracle DOES fold that trailing `--format json` into one JSON version
541
+ envelope (tan-cli#326's own repro; verified against `target/debug/tan.exe
542
+ --version sdk current --format json`) -- clap's real version handling
543
+ reads the format value from a scan of the whole process argv at the
544
+ moment it fires, not from however far its own structured parse had
545
+ gotten. A raw scan is what reaches that value from here too. It also
546
+ keeps the three narrower, Click-parseable cases correct as a side effect
547
+ (verified against the oracle for all four): `--format json --version` and
548
+ `--version --format json` both choose JSON (the literal text is present
549
+ either way); `--version --format bogus` chooses plain text (the literal
550
+ "json" is absent, so this never even asks whether "bogus" is a valid
551
+ value -- matching the oracle exactly, which also never validates it once
552
+ `--version` has already won).
553
+ """
554
+ if not value or ctx.resilient_parsing:
555
+ return value
556
+ if _wants_json(sys.argv[1:]):
557
+ # Under `--format json`, stdout is the envelope channel even for
558
+ # `--version`: Rust routes clap's version output through
559
+ # `emit_parse_error` (main.rs), giving exit 0, no `issues`, and the
560
+ # rendered line as `data.message`.
561
+ emit(
562
+ Envelope(
563
+ "cli",
564
+ Project(root=None, board_yaml=None),
565
+ {"message": f"tan {TAN_VERSION}"},
566
+ [],
567
+ ExitCode.SUCCESS,
568
+ )
569
+ )
570
+ else:
571
+ # MUST match /^tan \d+\.\d+\.\d+/ -- the extension rejects the binary
572
+ # otherwise (alp-sdk-vscode/src/alpCli/service.ts:107-121).
573
+ typer.echo(f"tan {TAN_VERSION}")
574
+ raise typer.Exit()
575
+
576
+
577
+ @app.callback(invoke_without_command=True)
578
+ def root(
579
+ ctx: typer.Context,
580
+ version: bool = typer.Option(
581
+ False, "--version", callback=_version_callback, is_eager=True
582
+ ),
583
+ output_format: str = typer.Option(
584
+ None,
585
+ "--format",
586
+ metavar="FORMAT",
587
+ help="Output format: text or json.",
588
+ callback=_format_callback,
589
+ is_eager=True,
590
+ ),
591
+ ) -> None:
592
+ """tan CLI -- board configuration, generation, and project tooling."""
593
+ # The `ctx.obj["format"]` seam thirteen command modules read as a fallback
594
+ # (`output_format or (ctx.obj or {}).get("format") or "text"`). Since
595
+ # tan-cli#378 relocated the pre-subcommand `--format` past the subcommand
596
+ # name, those reads land on the command's OWN parameter and this is `None`
597
+ # on every argv that names a command -- kept because the seam is what
598
+ # makes the two positions interchangeable if a future argv shape reaches a
599
+ # command without going through `_reorder_global_flags`, and because
600
+ # dropping it would leave `ctx.obj` unset for readers that would then have
601
+ # to be edited in lockstep for no behavioural gain.
602
+ ctx.obj = {"format": output_format}
603
+ if ctx.invoked_subcommand is None:
604
+ # Bare invocation. Rust's clap requires a subcommand and exits 2 with
605
+ # help on stderr (crates/tan-cli/src/cli.rs); invoke_without_command
606
+ # exists only so --version can run without one, so an actually-bare
607
+ # call has to be rejected by hand here, or `tan` with no args
608
+ # silently "succeeds" -- the defect this module exists to fix. Click's
609
+ # own idiom for "this callback found the invocation invalid":
610
+ # ctx.fail() raises its usual UsageError (usage line + message to
611
+ # stderr, exit code 2), the same shape every other CLI mistake here
612
+ # already gets, so bare invocation does not need its own bespoke
613
+ # rendering.
614
+ ctx.fail(
615
+ "a command is required.\n"
616
+ "New here? `tan doctor` checks this host, `tan init` creates a project, "
617
+ "`tan build` builds it.\n"
618
+ "`tan --help` lists all commands by category."
619
+ )
620
+ # No third branch here any more (tan-cli#378). This callback used to refuse
621
+ # a pre-subcommand `--format` for any command outside a hand-written
622
+ # allowlist of commands that honoured it -- the refusal was correct given the
623
+ # premise (a command that merely ACCEPTED the flag and ran in text mode
624
+ # would exit 0 with nothing on stdout), but the premise stopped holding
625
+ # once `_reorder_global_flags` relocates `--format` to where the command's
626
+ # own, always-read parameter picks it up. The allowlist reached 12 of 32
627
+ # commands and stalled, so 20 valid oracle invocations exited 2 with
628
+ # `command: "cli"`; deleting the branch is what makes the flag global for
629
+ # all 32 with nothing left to keep in sync.
630
+
631
+
632
+ def _wants_json(argv: list[str]) -> bool:
633
+ """Textual scan for ``--format json`` / ``--format=json``, mirroring
634
+ Rust's ``wants_json`` (crates/tan-cli/src/main.rs). Needed because a
635
+ usage error (bare invocation, an unknown command, a bad flag) means Click
636
+ exits via its own machinery before any option ever gets parsed into
637
+ something this code could otherwise trust.
638
+
639
+ Deliberately still `--`-blind and arity-blind, exactly like Rust's: this is
640
+ the PARSE-FAILURE arm's scan, not the run's channel. Since tan-cli#546 the
641
+ channel a DISPATCHED command answers on comes from `_DispatchedCommand` --
642
+ the command's own resolved `--format` -- and this scan only decides what a
643
+ run that never reached a command body does. Making it cleverer is what the
644
+ two reverted attempts on #546 did; see `_dispatched_json_mode`'s own comment
645
+ for the oracle measurement that rules out any third textual shape too.
646
+ """
647
+ for i, arg in enumerate(argv):
648
+ if arg == "--format=json":
649
+ return True
650
+ if arg == "--format" and i + 1 < len(argv) and argv[i + 1] == "json":
651
+ return True
652
+ return False
653
+
654
+
655
+ def _usage_error_envelope(exit_code: int, captured_stderr: str = "") -> str:
656
+ """The JSON envelope for a Click-level usage error under `--format json`.
657
+
658
+ `captured_stderr` is Click's own rendered message (usage line + the
659
+ specific complaint, e.g. "Error: No such option: --bogus") -- tee'd off
660
+ the real stderr stream by the caller as it printed, not recovered from the
661
+ exception object. Without it this function had exactly one message for
662
+ EVERY usage error ("invalid command line invocation"), so the actual
663
+ reason a caller's argv was rejected existed nowhere: not on stdout (this
664
+ generic string), and not on stderr either (the pre-fix caller discarded
665
+ the capture whenever no command-level envelope had been emitted, which is
666
+ precisely the case here). Rust's ``emit_parse_error`` (main.rs) affords
667
+ the specific clap message because it intercepts the error object itself,
668
+ before clap prints anything; recovering the equivalent object here would
669
+ mean depending on Typer's private, vendored click-alike exception
670
+ hierarchy (`typer._click.exceptions`, NOT the public `click` package's
671
+ classes -- confirmed empirically against typer==0.27.0/click==8.4.1:
672
+ TyperGroup and everything it raises descends from
673
+ `typer._click.core`/`exceptions`, not `click`'s own); tee-ing the text
674
+ Click already rendered is the version-stable seam instead.
675
+ """
676
+ message = captured_stderr.strip() or "invalid command line invocation"
677
+ env = Envelope(
678
+ "cli",
679
+ Project(root=None, board_yaml=None),
680
+ {"message": message},
681
+ [Issue("cli.parse-error", "error", message)],
682
+ exit_code,
683
+ )
684
+ return env.to_json()
685
+
686
+
687
+ #: The POSIX 128+SIGINT exit code Ctrl-C lands on. It does NOT reach `main()`
688
+ #: as a raw `KeyboardInterrupt`: Typer's own command wrapper catches it wherever
689
+ #: in the command's call stack it was raised and re-raises `Exit(130)`, which
690
+ #: Click's `Command.main` turns into a plain `sys.exit(130)`. So by the time the
691
+ #: handler in `main()` sees it, an interrupted run is indistinguishable from any
692
+ #: other non-zero exit that emitted no envelope -- and used to fall into the
693
+ #: same `_usage_error_envelope` branch every genuine Click usage error does.
694
+ #: Measured on `dev` (SIGINT 4s into a real `tan build --plan-from ... --execute
695
+ #: --format json` whose slice spawns `sleep 30`): exit 130 and
696
+ #: `{"command":"cli","exitCode":130,...,"code":"cli.parse-error","message":
697
+ #: "invalid command line invocation"}` -- an envelope asserting the COMMAND LINE
698
+ #: was invalid for a run that was already programming a board, at an exit code
699
+ #: outside the contract's fixed 0-5 set (tan-cli#491 defect 4).
700
+ _SIGINT_EXIT_CODE = 130
701
+
702
+ #: What the interrupt envelope says on both channels. Not command-specific:
703
+ #: `KeyboardInterrupt` reaches the handler identically from all 32 subcommands,
704
+ #: and the command that was running has already lost its own `data` by then.
705
+ _INTERRUPTED_MESSAGE = "interrupted (Ctrl-C)"
706
+
707
+
708
+ def _interrupted_envelope() -> str:
709
+ """The one JSON envelope an interrupted `--format json` run reports.
710
+
711
+ `cli.interrupted` names what actually happened. `ExitCode.RUNTIME_FAILURE`
712
+ (1), not 130: the envelope contract fixes `exitCode` to `tan.exit_codes.
713
+ ExitCode`'s 0-5, and the wire invariant `process exit code ==
714
+ envelope.exitCode` (tan-cli#327) means the two cannot disagree -- so the
715
+ caller of this function exits 1 as well. A DELIBERATE divergence from the
716
+ oracle, which has no SIGINT handler at all and simply dies from the signal
717
+ with zero bytes on stdout; the port cannot copy that and still keep "stdout
718
+ is the envelope channel" for a `--format json` run. TEXT mode is untouched
719
+ and still exits 130 through Click's own machinery, so an operator's shell
720
+ still sees the signal.
721
+ """
722
+ env = Envelope(
723
+ "cli",
724
+ Project(root=None, board_yaml=None),
725
+ {"message": _INTERRUPTED_MESSAGE},
726
+ [Issue("cli.interrupted", "error", _INTERRUPTED_MESSAGE)],
727
+ int(ExitCode.RUNTIME_FAILURE),
728
+ )
729
+ return env.to_json()
730
+
731
+
732
+ class _TeeStderr:
733
+ """Writes through to the REAL stderr immediately, while also keeping a
734
+ copy -- needed only to fold a Click-level usage error's message into the
735
+ JSON envelope (`_usage_error_envelope`, above).
736
+
737
+ Pre-fix, `--format json` wrapped the whole run in
738
+ `contextlib.redirect_stderr(io.StringIO())`: nothing reached the real
739
+ stderr until the process was about to exit, so a long-running `tan build
740
+ --format json` against a real Zephyr tree printed NOTHING for the whole
741
+ build, then dumped it all at once -- a customer watching the console sees
742
+ a hang, not a build. Every write goes to `_real` first, synchronously, so
743
+ a slice's output (`build_cmd._stream`) streams exactly as it does in text
744
+ mode; the buffered copy exists purely so the `SystemExit` handler below can
745
+ read back what Click already printed.
746
+ """
747
+
748
+ def __init__(self, real: object) -> None:
749
+ self._real = real
750
+ self._buffer = io.StringIO()
751
+
752
+ def write(self, s: str) -> int:
753
+ self._buffer.write(s)
754
+ return self._real.write(s)
755
+
756
+ def flush(self) -> None:
757
+ self._real.flush()
758
+
759
+ def getvalue(self) -> str:
760
+ return self._buffer.getvalue()
761
+
762
+
763
+ def _reconfigure_stdio() -> None:
764
+ """Force UTF-8, LF-only stdout/stderr, once, at the process boundary.
765
+
766
+ Every command downstream just `print()`s -- correctness here is what
767
+ makes that safe. A normal Windows `TextIOWrapper` translates a written
768
+ `\\n` to `\\r\\n` and encodes with the process's ANSI code page, neither of
769
+ which the oracle's `serde_json`/`println!` output does. Both are visible
770
+ on stdout, not just in theory: measured, `tan completion --shell bash`
771
+ was 3975 bytes with 108 `\\r` where the oracle's was 3867 bytes with zero
772
+ -- and the emitted script is a hard syntax error when sourced in a strict
773
+ bash (`syntax error near unexpected token $'{\\r''`); `clean --format
774
+ json` and a bare `--format json` both ended `\\r\\n` too, so this is a
775
+ process-wide stdout-newline defect, not a completion-specific one. A
776
+ frozen/piped stream may not implement `.reconfigure()` (e.g. a test
777
+ harness's in-memory buffer) -- `hasattr` skips those rather than raising,
778
+ since the fix only matters for the real console/pipe case it targets.
779
+ """
780
+ for stream in (sys.stdout, sys.stderr):
781
+ if hasattr(stream, "reconfigure"):
782
+ stream.reconfigure(encoding="utf-8", newline="\n")
783
+
784
+
785
+ def main() -> None:
786
+ """Process entrypoint.
787
+
788
+ Text mode (the default) lets Click run standalone: it already prints its
789
+ own errors/help to stderr and exits with the right code, which is exactly
790
+ the contract there -- Click's exact RENDERING of a usage error carries no
791
+ promises of its own. That rule outlived its statement of record:
792
+ ``tests/parity/oracle.py`` declared it, scoped to the Rust-vs-Python diff,
793
+ and went with the oracle axis in tan-cli#269, so THIS docstring is where it
794
+ lives now. Read it narrowly. What is unpinned is Click/rich's wording and
795
+ box-drawing, because nothing diffs it against the retired binary's clap
796
+ renderer. tan's stderr as a CHANNEL is pinned hard -- ~500 assertions under
797
+ ``python/tests/`` take it as their subject, ``contract/envelopes``' goldens
798
+ require it EMPTY under ``--format json``, and
799
+ ``tests/test_stdout_bytes.py::test_stderr_also_has_no_cr`` forbids ``\r``
800
+ in it.
801
+
802
+ ``--format json`` cannot be handled that way: Click's default dispatch
803
+ prints straight to stdout/stderr and calls `sys.exit` itself for a usage
804
+ error, none of which goes through the envelope, so a bare invocation or a
805
+ bad flag under `--format json` would otherwise leave stdout either empty
806
+ or carrying human text instead of the one JSON envelope the contract
807
+ promises (the hard constraint: "stdout is the envelope channel"). Rust's
808
+ ``main.rs`` hits the identical problem and solves it by intercepting the
809
+ parse error before clap prints it; the equivalent interception point here
810
+ is process exit itself -- `app()` still runs standalone (so its stderr
811
+ text and exit code are unchanged), and this wraps it only to add the
812
+ missing stdout envelope when the exit signals failure under `--format
813
+ json`.
814
+ """
815
+ global _dispatched_json_mode
816
+
817
+ _reconfigure_stdio()
818
+ # Reset per run, not merely initialised at import: `main()` is called more
819
+ # than once per process by the in-process tests, and a stale answer from the
820
+ # previous run would decide this one's output channel.
821
+ _dispatched_json_mode = None
822
+ original_argv = sys.argv[1:]
823
+ argv = _reorder_global_flags(original_argv)
824
+ sys.argv = [sys.argv[0], *argv]
825
+ # Scanned off the ORIGINAL argv, not the rewritten one: `_reorder_global_flags`
826
+ # COLLAPSES to just the offending `--format <bad-value>` pair the moment a
827
+ # pre-subcommand `--format` carries a value the oracle's global `--format`
828
+ # does not have (see its own docstring), which drops every later token --
829
+ # including a SECOND, accepted `--format json` the oracle still notices when
830
+ # it decides which channel to answer on. Measured against the oracle: `tan
831
+ # --format sarif --format json validate --offline` answers a 423-byte JSON
832
+ # envelope on stdout (`wants_json` in Rust's own `main.rs` is a textual scan
833
+ # of the whole process argv too, not a re-parse of whatever clap's actual
834
+ # value resolution landed on). Reading the rewritten `argv` here instead
835
+ # left that same invocation with `--format json` already dropped by the
836
+ # collapse, so `json_mode` came back `False` and the usage-error fallback
837
+ # below never ran: `main()` fell into the text-mode branch, which prints
838
+ # Click's error to stderr only -- zero bytes on stdout, a hard violation of
839
+ # "stdout is the envelope channel" for an argv the caller marked `--format
840
+ # json`.
841
+ json_mode = _wants_json(original_argv)
842
+
843
+ if json_mode and _wants_help(argv):
844
+ # `--help` short-circuits Click before `root`/any command ever runs
845
+ # (see `_emit_help_envelope`), so it needs its own path entirely --
846
+ # by the time a `SystemExit` from it would reach the block below,
847
+ # Click has already printed the human help text straight to stdout.
848
+ # `sys.exit`, not a bare `return`: the process exit code must agree
849
+ # with the `exitCode` of the envelope just printed (Rust's own
850
+ # `json_exit_code` doc comment states the same invariant) --
851
+ # `tan --format json badcmd --help` renders help for an unknown
852
+ # command at exit 2, and a bare `return` here left the process exiting
853
+ # 0 regardless.
854
+ sys.exit(_emit_help_envelope(argv))
855
+
856
+ if not json_mode:
857
+ # `prog_name="tan"` -- Click otherwise derives the name it prints in
858
+ # `Usage: ...` from `os.path.basename(sys.argv[0])`, which is the
859
+ # frozen binary's OWN filename (`tan.exe` locally, or whatever
860
+ # `release.yml` renamed the uploaded asset to, e.g.
861
+ # `tan-x86_64-pc-windows-msvc.exe`, if a user runs the download in
862
+ # place). Pinned here rather than left to derive, same as
863
+ # `_emit_help_envelope`'s `prog_name="tan"` above.
864
+ app(prog_name="tan")
865
+ return
866
+
867
+ # `--format json`, past `--help`: TEE stderr for the duration of the run
868
+ # (`_TeeStderr`) rather than capturing it -- every write still reaches the
869
+ # real stderr AS IT HAPPENS, so a slice's live output (build's `_stream`)
870
+ # streams exactly as it does in text mode; a long `tan build --format
871
+ # json` against a real Zephyr tree no longer goes silent for the whole
872
+ # build and dumps at the end. The kept copy exists only to fold Click's
873
+ # own pre-dispatch usage-error text (bare invocation, an unknown command,
874
+ # a bad flag -- printed straight to stderr before any command runs,
875
+ # mirroring clap's `err.exit()`) into the envelope below via
876
+ # `_usage_error_envelope`, so the specific reason a caller's argv was
877
+ # rejected is not silently different between the two channels.
878
+ # `not envelope_emitted()` -- the same flag `emit()` sets -- still gates
879
+ # the envelope fallback itself: a command that already wrote its own and
880
+ # then exited non-zero (every failed `tan build`) must not get a second
881
+ # one appended, two JSON documents on stdout is the same break as none.
882
+ real_stderr = sys.stderr
883
+ captured_stderr = _TeeStderr(real_stderr)
884
+ sys.stderr = captured_stderr
885
+ try:
886
+ try:
887
+ # `prog_name="tan"` -- see the text-mode call above; it matters MORE
888
+ # here, since a Click usage error's rendered message (captured via
889
+ # `_TeeStderr`) is what `_usage_error_envelope` folds verbatim into
890
+ # `data.message`, a machine-readable envelope field a consumer
891
+ # should not see vary with how the binary happened to be named.
892
+ app(prog_name="tan")
893
+ except SystemExit as exc:
894
+ code = exc.code
895
+ if code is None:
896
+ code = int(ExitCode.SUCCESS)
897
+ elif not isinstance(code, int):
898
+ code = int(ExitCode.RUNTIME_FAILURE)
899
+ if not envelope_emitted():
900
+ # `_dispatched_json_mode is False` -- a command's body really
901
+ # RAN, and its OWN `--format` resolved to text. That run owes
902
+ # stdout nothing (text mode carries no envelope promise), so
903
+ # neither of the two fallbacks below may fire for it: printing
904
+ # one is how `tan quality -- --format json` used to answer a
905
+ # forwarded west failure with `command:"cli"` /
906
+ # `cli.parse-error` on stdout for a caller that asked for text
907
+ # (tan-cli#546). `None` -- nothing dispatched -- means the parse
908
+ # itself failed, which is precisely Rust's `Err` arm, and the
909
+ # textual `_wants_json` scan that got us into this branch is
910
+ # exactly what Rust consults there.
911
+ ran_in_text_mode = _dispatched_json_mode is False
912
+ if code == _SIGINT_EXIT_CODE and not ran_in_text_mode:
913
+ # Ahead of the generic usage-error fallback: an interrupted
914
+ # run is not a usage error, and `sys.exit` here (rather than
915
+ # re-raising `exc`) is what keeps the process code equal to
916
+ # the `exitCode` the envelope just printed. See
917
+ # `_interrupted_envelope`.
918
+ print(_interrupted_envelope())
919
+ sys.exit(int(ExitCode.RUNTIME_FAILURE))
920
+ if code != 0 and not ran_in_text_mode:
921
+ print(_usage_error_envelope(code, captured_stderr.getvalue()))
922
+ raise
923
+ # tan-cli#327: `Envelope.to_json()`'s serialize-failure fallback
924
+ # can report a different `exitCode` (5, `envelope.serialize-
925
+ # failed`) than the command's own `typer.Exit(code)` -- the
926
+ # command chose `code` BEFORE `emit()` ever tried to encode the
927
+ # envelope, so a fallback there leaves `code` stale. The wire
928
+ # invariant is `process exit code == envelope.exitCode`
929
+ # (mirrors the Rust `json_exit_code` boundary and its
930
+ # `json_exit_code_follows_serialize_failure_fallback_not_stale_
931
+ # run_exit` test); `emit()` is the one place that already knows
932
+ # the REAL code, so read it back rather than re-deriving
933
+ # anything from the JSON this process just printed.
934
+ emitted_code = envelope_emitted_exit_code()
935
+ if emitted_code is not None and emitted_code != code:
936
+ sys.exit(emitted_code)
937
+ raise
938
+ finally:
939
+ sys.stderr = real_stderr