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/envelope.py ADDED
@@ -0,0 +1,647 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Machine-readable result envelope. JSON mode writes exactly one to stdout."""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ import os
8
+ import re
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from tan.core.sdk_discovery import sdk_ladder_divergence_issue, sdk_resolution_issues
14
+ from tan.exit_codes import ExitCode
15
+
16
+ #: Every code point in the UTF-16 surrogate range. A Python `str` may hold one
17
+ #: UNPAIRED -- `os.fsdecode`/`surrogateescape` turns each byte of an
18
+ #: un-decodable filesystem name into exactly one of these (`proj\xffx` ->
19
+ #: `proj\udcffx`) and carries it verbatim into `project.root`, an argv token, a
20
+ #: scanned path in `data`. It is a perfectly ordinary character to `json.dumps`,
21
+ #: which writes it straight through under `ensure_ascii=False` -- but it is NOT
22
+ #: valid UTF-8, so the failure used to land one call later, at `emit()`'s
23
+ #: `print(text)`, as an uncaught `UnicodeEncodeError` AFTER `_serialise()` had
24
+ #: already reported success (tan-cli#491). See `_scrub_lone_surrogates`.
25
+ _LONE_SURROGATE = re.compile("[\ud800-\udfff]")
26
+
27
+ #: What each one becomes: U+FFFD REPLACEMENT CHARACTER, which is precisely what
28
+ #: the frozen v0.4.1 oracle puts on the wire for the same directory. Measured,
29
+ #: not assumed -- `target/debug/tan inspect --format json` run from a directory
30
+ #: created as `os.fsdecode(b"proj\xffx")` answers
31
+ #: `"root":"...\/proj\xef\xbf\xbdx"` at exit 0 (`\xef\xbf\xbd` is U+FFFD's UTF-8
32
+ #: encoding), because every path Rust puts in the envelope goes through
33
+ #: `Path::to_string_lossy`, whose lossy step is this same substitution, one
34
+ #: replacement character per un-decodable byte. `surrogateescape` is also one
35
+ #: surrogate per un-decodable byte, so the two agree character for character.
36
+ _REPLACEMENT_CHARACTER = "\ufffd"
37
+
38
+
39
+ def _scrub_lone_surrogates(text: str) -> str:
40
+ """`text` with every surrogate code point replaced by U+FFFD.
41
+
42
+ Applied to the SERIALISED JSON rather than walked over the payload: a
43
+ surrogate can arrive in a `data` value, in a `project.root`, in an issue
44
+ MESSAGE, or in a dict KEY, and one pass over the finished document catches
45
+ all four for the cost of one regex scan instead of a second full recursive
46
+ walk beside `json_safe_floats`. Nothing else in the document can be a
47
+ surrogate -- an astral character is a single non-surrogate code point in a
48
+ Python `str`, and `json.dumps` escapes the only characters it rewrites
49
+ (`"`, `\\`, controls) into ASCII -- so every match came from the payload.
50
+
51
+ Deliberately NOT `ensure_ascii=True` as a fallback re-serialisation (the
52
+ first shape tried for tan-cli#491, and rejected against the oracle):
53
+ that escapes the surrogate as `\\udcff`, which is parseable JSON but is not
54
+ what the oracle emits, and it also escapes every OTHER non-ASCII character
55
+ in the same document -- so one bad byte in a path would have turned a
56
+ perfectly good `Sensör Ölçüm` elsewhere in `data` into `Sens\\u00f6r`,
57
+ re-opening the byte-for-byte divergence `ensure_ascii=False` exists to
58
+ close. This substitution touches only the offending characters.
59
+ """
60
+ return _LONE_SURROGATE.sub(_REPLACEMENT_CHARACTER, text)
61
+
62
+
63
+ #: OPEN QUESTION, deliberately left open (tan-cli#491). This substitution is
64
+ #: SILENT: nothing in the envelope tells a consumer that a value it is reading
65
+ #: was rewritten, so a path that differs from the real one only in the bytes
66
+ #: that were replaced reads as authoritative. Announcing it -- a `warning`
67
+ #: issue beside the payload -- was considered and NOT done, because the
68
+ #: substitution was chosen for byte parity with the frozen v0.4.1 oracle
69
+ #: (`Path::to_string_lossy` makes the identical replacement and emits no issue
70
+ #: of its own), so adding one changes the wire bytes of a case that currently
71
+ #: matches by construction. `--format json` is a machine contract; that is a
72
+ #: divergence to decide deliberately, not a side effect of a bug fix. Left for
73
+ #: the maintainer with the trade-off recorded rather than settled here.
74
+
75
+
76
+ #: tan-cli#491's invariant is that NO payload, however malformed, may stop the
77
+ #: single envelope from being written -- the same claim `_serialise`'s own
78
+ #: `# noqa: BLE001 -- no payload may ever crash stdout` has always made. After
79
+ #: the surrogate scrub above, TWO sites still broke it, both measured on
80
+ #: `dev`@`4dcfdf5`, not theorised:
81
+ #:
82
+ #: * `_serialise`'s `except` arm re-serialises `self.project` (and
83
+ #: `self.sdk`) VERBATIM, so a value there that `json.dumps` cannot encode
84
+ #: detonates the very fallback that exists to keep stdout alive:
85
+ #: `Envelope("x", Project(root=<non-str>, board_yaml=None), {"ok": 1}, [],
86
+ #: 0).to_json()` raised `TypeError: Object of type ... is not JSON
87
+ #: serializable` straight out of the arm -- zero bytes on stdout through
88
+ #: `emit()`, a raw traceback through `tan.cli`'s two `to_json()` callers.
89
+ #: * `emit()`'s `print(text)` sat outside every guard. That is the exact
90
+ #: placement error #491 names: `_serialise()` returns a `str` and reports
91
+ #: success, and the ENCODE happens one call later, at the write.
92
+ #:
93
+ #: `_last_resort_document` is what both sites fall back to. It is deliberately
94
+ #: `ensure_ascii=True` -- the opposite of `_serialise`, which needs
95
+ #: `ensure_ascii=False` for byte parity on the normal path. Here the document's
96
+ #: job is to be writable to a stdout that has ALREADY refused one string, so
97
+ #: pure ASCII (encodable under utf-8, ascii, latin-1, every Windows code page)
98
+ #: is worth more than parity. It is also why no surrogate scrub is needed on
99
+ #: this path: `ensure_ascii=True` renders one as the escape `\udcff`, ASCII on
100
+ #: the wire.
101
+ #:
102
+ #: TWO of its fields are not literals, and neither is trusted. `command` is
103
+ #: typed `str` but nothing enforces that at runtime, and a non-`str` there made
104
+ #: the document itself raise (`TypeError: Object of type ... is not JSON
105
+ #: serializable`) -- taking `to_json()` AND `emit()` down with it, since
106
+ #: `emit`'s own arm calls this with the same argument. The reason text runs
107
+ #: `err.__str__`, which is payload code too. Both go through `str()` inside a
108
+ #: guard, so the only way out of the function is a document. `"cli"` is the
109
+ #: fallback command name because `tan.cli`'s own envelopes already use it for a
110
+ #: run with no resolved subcommand -- a value a consumer already handles.
111
+ #:
112
+ #: Both guards catch `Exception`, deliberately NOT `BaseException`: a `__str__`
113
+ #: that raises `KeyboardInterrupt` escapes intact, and that is correct.
114
+ #: Swallowing an interrupt is the defect #491's own Ctrl-C arm exists to fix.
115
+ #:
116
+ #: `envelope.serialize-failed` and `ExitCode.INTERNAL_FAILURE` (5) reuse the
117
+ #: existing fallback's code and exit code rather than introducing new ones: the
118
+ #: consumer effect is identical (tan could not represent this run's output),
119
+ #: and `contract/issue-codes.json` already registers it.
120
+ def _last_resort_document(command: str, err: Exception) -> str:
121
+ """One parseable envelope built from ASCII literals and two COERCED
122
+ strings -- what stdout gets when neither the real envelope nor
123
+ `_serialise`'s own fallback could be produced or written. See the notes
124
+ above for the two sites, the two coercions, and the reused issue code.
125
+ """
126
+ try:
127
+ reason = f"{type(err).__name__}: {err}"
128
+ except Exception: # noqa: BLE001 -- an exception's own __str__ may raise
129
+ reason = type(err).__name__
130
+ try:
131
+ name = str(command)
132
+ except Exception: # noqa: BLE001 -- so may an object standing in for a command name
133
+ name = "cli"
134
+ return json.dumps(
135
+ {
136
+ "command": name,
137
+ "ok": False,
138
+ "exitCode": int(ExitCode.INTERNAL_FAILURE),
139
+ # Never the real `project`/`sdk`/`data`: carrying them through is
140
+ # precisely what broke the arm that sent us here. Hand-built rather
141
+ # than `Project.as_dict()`/`Issue.as_dict()` for the same reason --
142
+ # this document may not call code that can raise. The cost is that
143
+ # a field added to either type would silently give this envelope a
144
+ # different shape from every other one on the contract, which
145
+ # `test_the_last_resort_document_has_the_same_key_set_as_a_normal_envelope`
146
+ # is what notices.
147
+ "project": {"root": None, "boardYaml": None},
148
+ "data": None,
149
+ "issues": [
150
+ {
151
+ "code": "envelope.serialize-failed",
152
+ "severity": "error",
153
+ "message": f"failed to write command output: {reason}",
154
+ }
155
+ ],
156
+ },
157
+ separators=(",", ":"),
158
+ ensure_ascii=True,
159
+ )
160
+
161
+
162
+ def json_safe_floats(value: Any) -> Any:
163
+ """`value` with every NON-FINITE float replaced by `None`, recursively --
164
+ `serde_json`'s own answer for an `f64` RFC 8259 cannot express (tan-cli#387).
165
+
166
+ Python's `json.dumps` defaults to `allow_nan=True` and writes the
167
+ non-standard literals `Infinity`, `-Infinity` and `NaN`. Those are not JSON.
168
+ `JSON.parse` throws on them, and the alp-sdk-vscode extension's only channel
169
+ is this envelope -- so a `build/system-manifest.yaml` carrying `.inf` in
170
+ `hw_info` (verbatim into `data` via `raw_passthrough`) used to hand the
171
+ consumer a parse throw at exit code 0 with `issues:[]`: no coded signal to
172
+ fall back on, and nothing to distinguish it from tan crashing. The oracle on
173
+ the identical input emits `null` and the consumer gets a field it can
174
+ inspect.
175
+
176
+ NOT solved with `allow_nan=False` at the `json.dumps` below. That raises,
177
+ which lands in `_serialise`'s `envelope.serialize-failed` / exit-5 fallback
178
+ where the oracle exits 0 -- one divergence traded for another. This projects
179
+ instead, so the exit code and `issues` stay exactly what the command decided.
180
+
181
+ Applied at SERIALISE time, mirroring where `serde_json` makes the same
182
+ substitution, so it holds for every command's `data` rather than for the one
183
+ payload the defect was found in.
184
+
185
+ Map KEYS are deliberately left alone: `json.dumps` renders a non-finite
186
+ float key as the *string* `"Infinity"`, which is already valid JSON, and
187
+ rewriting it would change a key a consumer may be matching on.
188
+
189
+ A new object always -- the caller's payload is never mutated, so a command
190
+ that inspects its own `data` after `emit()` still sees its own floats.
191
+ """
192
+ if isinstance(value, float) and not math.isfinite(value):
193
+ return None
194
+ if isinstance(value, dict):
195
+ return {k: json_safe_floats(v) for k, v in value.items()}
196
+ if isinstance(value, (list, tuple)):
197
+ # A tuple becomes a list: `json.dumps` writes both as a JSON array, so
198
+ # the wire bytes are unchanged.
199
+ return [json_safe_floats(v) for v in value]
200
+ return value
201
+
202
+
203
+ @dataclass(frozen=True)
204
+ class Issue:
205
+ code: str
206
+ severity: str
207
+ message: str
208
+
209
+ def as_dict(self) -> dict[str, str]:
210
+ return {"code": self.code, "severity": self.severity, "message": self.message}
211
+
212
+
213
+ @dataclass(frozen=True)
214
+ class Project:
215
+ root: str | None
216
+ board_yaml: str | None
217
+
218
+ def as_dict(self) -> dict[str, Any]:
219
+ return {"root": self.root, "boardYaml": self.board_yaml}
220
+
221
+ @staticmethod
222
+ def resolved(root: str | None, board_yaml: str | None) -> "Project":
223
+ """The `project` block for a RESOLVED project -- the one seam every
224
+ command that reports a real (as opposed to hardcoded-None) `board_yaml`
225
+ should build it through (tan-cli#236, #170; mirrors the Rust CLI's
226
+ `Project::from_context` in `crates/tan-cli/src/envelope.rs`).
227
+
228
+ `board_yaml` is reported only when a file is really there. The doc has
229
+ always read "if found"; before this fix, every call site cloned a
230
+ resolver's `<root>/board.yaml` straight through regardless of whether
231
+ anything was there, so a consumer that opened it got ENOENT. `null` is
232
+ not a new value here -- it is what the field already carries wherever
233
+ resolution finds nothing.
234
+
235
+ `root` passes through untouched -- tan-cli#236 rules a
236
+ directory-is-not-a-project question explicitly out of scope.
237
+
238
+ Deliberately NOT pushed into the resolvers that compute `board_yaml`
239
+ (`resolve_project_paths`, `resolve_project_context`, ...): several
240
+ commands need that unfiltered path precisely when the file is absent --
241
+ a "no board.yaml at `<path>`" refusal message, in particular -- so
242
+ nulling it at the resolver would strip the path out of the very message
243
+ that names it. Reporting is the seam; the resolver is not.
244
+ """
245
+ exists = board_yaml is not None and os.path.exists(board_yaml)
246
+ return Project(root=root, board_yaml=board_yaml if exists else None)
247
+
248
+
249
+ @dataclass(frozen=True)
250
+ class SdkInfo:
251
+ root: str
252
+ source_tier: str
253
+ #: tan-cli#478. CARRIED from the resolution, never re-derived at emit time,
254
+ #: and deliberately NOT part of `as_dict` -- the wire shape stays
255
+ #: `{root, sourceTier}`, which is the extension handshake.
256
+ #:
257
+ #: Carried rather than recomputed because `~/.alp/sdk-default` is MUTABLE
258
+ #: mid-run: `bootstrap` rewrites it with its own `writtenFor` before its
259
+ #: envelope goes out, and `init` writes the project pin after resolving.
260
+ #: `test_bootstrap_command.py` pins the resolution-time semantic ("`init`
261
+ #: surfaces `sdk.global-default-foreign-project` BEFORE `_pin_sdk`
262
+ #: writes"), so an emit-time re-read would report the POST-mutation state
263
+ #: and the warning would vanish on exactly the two commands that change
264
+ #: it. That is the difference from `_with_sdk_divergence` below, which may
265
+ #: re-derive safely: #407 is a stateless property of the filesystem,
266
+ #: #464 is a property of the resolution THIS run acted on.
267
+ foreign_global_default_for: str | None = None
268
+ #: The unreadable/unresolvable `.alp/sdk-path` pin, same carry-through --
269
+ #: `sdk_resolution_issues` emits the two together, in this order.
270
+ broken_project_pin: str | None = None
271
+
272
+ @classmethod
273
+ def from_resolution(cls, root: str, resolution: Any) -> SdkInfo:
274
+ """The ONE blessed constructor. `resolution` is any object carrying
275
+ `tier`/`foreign_global_default_for`/`broken_project_pin` -- every
276
+ ladder already answers with one (`SdkRootResolution`, `ActiveSdk`).
277
+
278
+ A raw `SdkInfo(root, tier)` silently drops both facts, which is how 16
279
+ of ~32 commands ended up disclosing a foreign global default and the
280
+ rest staying quiet; `tests/gates/test_sdk_info_is_built_from_a_resolution.py`
281
+ refuses new raw constructions outside the seams that predate this.
282
+ """
283
+ return cls(
284
+ root,
285
+ resolution.tier,
286
+ getattr(resolution, "foreign_global_default_for", None),
287
+ getattr(resolution, "broken_project_pin", None),
288
+ )
289
+
290
+ def as_dict(self) -> dict[str, str]:
291
+ # Forward slashes ALWAYS, mirroring `crates/tan-cli/src/sdk_report.rs`'s
292
+ # `root.replace('\\', "/")` and the guarantee its own doc comment makes:
293
+ # "`sdk.root` never diverges by separator style depending on which
294
+ # resolver happened to record it." This is part of the extension
295
+ # handshake, so it must be platform-identical.
296
+ #
297
+ # Normalised HERE, at the one shared seam, rather than in each command:
298
+ # `build`, `doctor` and `sdk` all populate this field and all three had
299
+ # the same defect. No conformance fixture can catch it -- `sdk` is absent
300
+ # from every committed golden, because none of them resolves a checkout.
301
+ # `data.sdkPath` stays raw/native on both sides; only this key is posix.
302
+ return {"root": self.root.replace("\\", "/"), "sourceTier": self.source_tier}
303
+
304
+
305
+ @dataclass
306
+ class SdkDisclosure:
307
+ """What a command already knew about its SDK resolution at the moment it
308
+ blew up, so its OUTER `<command>.internal-failure` catch-all can report it
309
+ (tan-cli#497 defect 2, the site the first pass missed).
310
+
311
+ Every command here splits into a `_run`-style inner function that resolves
312
+ the SDK and an outer wrapper that guards the whole thing with a bare
313
+ `except Exception`. The facts -- which checkout answered, and the
314
+ `sdk.project-pin-unresolved` / `sdk.global-default-foreign-project` pair
315
+ that says the pin was ignored -- are computed INSIDE the inner function, so
316
+ the handler in the outer one had no name to read them from and reported the
317
+ crash alone. `model`/`run` avoided that by resolving in the outer function;
318
+ the three that could not (their resolution needs paths the inner function
319
+ resolves) pass one of these down and `record()` into it the instant the
320
+ pair is known.
321
+
322
+ MUTABLE and shared by reference on purpose: the handler must see what the
323
+ inner call recorded before it raised. Reading an unrecorded disclosure is
324
+ the honest "nothing was resolved yet" -- `None` and `[]`, exactly what a
325
+ crash before the ladder ran should report.
326
+ """
327
+
328
+ sdk: SdkInfo | None = None
329
+ issues: list[Issue] = field(default_factory=list)
330
+
331
+ def record(self, sdk: SdkInfo | None, issues: list[Issue]) -> None:
332
+ """Copies `issues` rather than aliasing it: the caller goes on to
333
+ `append` command-specific issues onto its own list, and those are not
334
+ resolution facts -- a crash after them must not report them twice."""
335
+ self.sdk = sdk
336
+ self.issues = list(issues)
337
+
338
+
339
+ #: The three commands resolving the SDK through `resolve_sdk_root_wide` rather
340
+ #: than `resolve_sdk_root_ladder` (tan-cli#407). Only used to label WHICH side
341
+ #: of a divergence the reader is holding; getting it wrong would swap two
342
+ #: labels in a warning, never change which root a command uses.
343
+ WIDE_LADDER_COMMANDS = frozenset({"init", "generate", "examples"})
344
+
345
+
346
+ class Envelope:
347
+ def __init__(self, command, project, data, issues, exit_code, sdk=None):
348
+ self.command = command
349
+ self.project = project
350
+ self.data = data
351
+ issues = self._with_sdk_divergence(command, project, issues, sdk)
352
+ self.issues = self._with_sdk_resolution_advisories(issues, sdk)
353
+ self.exit_code = int(exit_code)
354
+ self.sdk = sdk
355
+
356
+ @staticmethod
357
+ def _with_sdk_resolution_advisories(issues, sdk):
358
+ """Append tan-cli#464's pair -- `sdk.project-pin-unresolved` and
359
+ `sdk.global-default-foreign-project` -- from what the resolution
360
+ already recorded on `sdk`.
361
+
362
+ Same argument as `_with_sdk_divergence` below, applied to a second
363
+ fact: doing it at the one seam every envelope passes through, instead
364
+ of at each command, is what stops the 33rd command from forgetting.
365
+ Before this, 16 of ~32 commands hand-called `sdk_resolution_issues`
366
+ and the rest silently resolved another project's checkout -- `validate`
367
+ spawning ITS schema validator for this project's board.yaml, `trace`
368
+ printing ITS orchestrator path -- at `ok: true, issues: []`.
369
+
370
+ DEDUPED BY CODE, and that is load-bearing rather than defensive: the
371
+ hand-call sites still exist, and several fold these two into a
372
+ command-specific ORDER this must not disturb. A command that already
373
+ emitted the pair keeps its own copy and position; one that never did
374
+ gets it here.
375
+
376
+ Appends to a NEW list -- several commands keep rendering their own
377
+ text from the list they passed in.
378
+
379
+ `sdk_resolution_issues` is imported at module level, from
380
+ `tan.core.sdk_discovery` (tan-cli#408 review follow-up) -- it used to
381
+ live in `sdk_cmd`, which imports this module at module level, so a
382
+ top-level import here was circular; moving `resolve_sdk_tiered` and
383
+ its two `Issue` builders alongside the ladders is what makes this
384
+ importable at the top of this file at all.
385
+ """
386
+ if sdk is None:
387
+ return issues
388
+ advisories = sdk_resolution_issues(
389
+ sdk.broken_project_pin, sdk.source_tier, sdk.foreign_global_default_for
390
+ )
391
+ if not advisories:
392
+ return issues
393
+ seen = {issue.code for issue in issues}
394
+ extra = [issue for issue in advisories if issue.code not in seen]
395
+ return [*issues, *extra] if extra else issues
396
+
397
+ @staticmethod
398
+ def _with_sdk_divergence(command, project, issues, sdk):
399
+ """Append the tan-cli#407 warning when the two SDK ladders would answer
400
+ DIFFERENT checkouts from this project root.
401
+
402
+ Done at the ONE seam every command's envelope passes through, not at
403
+ each of the 20 `SdkInfo(...)` construction sites: #407's whole
404
+ complaint is that the two ladders report the same `sourceTier`
405
+ ("discovery") for two roots, and a fix present on `build` but missing
406
+ on the other sixteen commands would leave exactly the silence the
407
+ issue is about -- the vscode extension branches on `sourceTier` from
408
+ `generate`/`examples` (wide) AND `build`/`doctor`/`sdk current`
409
+ (narrow), so partial coverage still gives it two roots it cannot tell
410
+ apart.
411
+
412
+ Gated on `sourceTier == "discovery"` before anything touches the disk,
413
+ which makes this free in every normal layout: any HIGHER tier
414
+ (`--sdk-root`, the project pin, the global default) is shared verbatim
415
+ by both ladders and cannot be the pair that differs, so there is
416
+ nothing to compare. That gate is also why passing `sdk_root_arg=None`
417
+ below is exact rather than approximate -- a `--sdk-root` run reports
418
+ `sdkRootFlag`, never `discovery`, so it never reaches here.
419
+
420
+ Appends to a NEW list. Mutating the caller's would be a side effect on
421
+ an argument, and several commands keep rendering their own text from
422
+ the list they passed in.
423
+
424
+ `sdk_ladder_divergence_issue` is imported at module level, from
425
+ `tan.core.sdk_discovery` (tan-cli#408) -- it used to live in
426
+ `build_cmd`, which imports this module at module level, so a
427
+ top-level import here was circular; the extraction is what makes it
428
+ importable at the top of this file at all.
429
+ """
430
+ if sdk is None or getattr(sdk, "source_tier", None) != "discovery":
431
+ return issues
432
+ # `project.root` is None for the commands that are not project-scoped
433
+ # -- `examples` and `sdk current` both report `{"root": null}` -- and
434
+ # those are exactly two of the commands #407 measured as divergent, so
435
+ # bailing on a null root would have left the reported collision
436
+ # unreported on the wide side. They resolved the SDK from the cwd, so
437
+ # that is the workspace root to ask about.
438
+ root = getattr(project, "root", None)
439
+ try:
440
+ # RESOLVED, never the raw `project.root`. Several commands report
441
+ # it as the relative `"."` (`validate` does), and `Path(".").parent`
442
+ # is `"."` -- so the ladders' lateral `../alp-sdk` candidate
443
+ # collapses onto the child `./alp-sdk` and a real divergence reads
444
+ # as agreement. Measured: from a cwd where `doctor` warned,
445
+ # `validate` did not, purely because of that one character.
446
+ start = Path(root).resolve() if root else Path.cwd()
447
+
448
+ divergence = sdk_ladder_divergence_issue(
449
+ None, start, wide=command in WIDE_LADDER_COMMANDS
450
+ )
451
+ except Exception: # noqa: BLE001
452
+ # A warning about ambiguity must never be the reason a command
453
+ # cannot report its actual result. Whatever the run was doing is
454
+ # more important than this advisory.
455
+ return issues
456
+ if divergence is None:
457
+ return issues
458
+ if any(getattr(i, "code", None) == divergence.code for i in issues):
459
+ return issues
460
+ return [*issues, divergence]
461
+
462
+ def _as_dict(self) -> dict[str, Any]:
463
+ out: dict[str, Any] = {
464
+ "command": self.command,
465
+ "ok": self.exit_code == 0,
466
+ "exitCode": self.exit_code,
467
+ "project": self.project.as_dict(),
468
+ }
469
+ # Absent, not null -- see test_sdk_key_is_absent_when_none_not_null.
470
+ if self.sdk is not None:
471
+ out["sdk"] = self.sdk.as_dict()
472
+ out["data"] = self.data
473
+ out["issues"] = [i.as_dict() for i in self.issues]
474
+ return out
475
+
476
+ def to_json(self) -> str:
477
+ return self._serialise()[0]
478
+
479
+ def _serialise(self) -> tuple[str, int]:
480
+ """The JSON text plus the exit code THAT TEXT actually reports.
481
+
482
+ The two differ only when serialisation itself fails: the fallback
483
+ below substitutes `ExitCode.INTERNAL_FAILURE` (5) for whatever
484
+ `self.exit_code` was, so a caller that wants the wire invariant
485
+ `process exit code == envelope.exitCode` to hold (tan-cli#327) must
486
+ read the code from HERE, not from `self.exit_code` -- `emit()` does
487
+ exactly that. `to_json()` stays a plain `str` return -- every existing
488
+ caller (`test_envelope.py`'s `json.loads(env.to_json())` assertions)
489
+ depends on that shape.
490
+ """
491
+ try:
492
+ # `ensure_ascii=False`: the default (True) escapes every non-ASCII
493
+ # codepoint as `\uXXXX`, which is valid JSON but not what the
494
+ # oracle emits -- `serde_json::to_string` writes raw UTF-8 bytes
495
+ # verbatim (measured: `scaffold --name "Sensör Ölçüm"` on the
496
+ # Rust CLI puts the literal `Sensör Ölçüm` on the wire, not
497
+ # `Sensör...`). A consumer that byte-compares tan's envelope
498
+ # against the oracle's, or that greps stdout for a raw non-ASCII
499
+ # string, saw a divergence stdout never had a reason to carry.
500
+ #
501
+ # `json_safe_floats`: `Infinity`/`-Infinity`/`NaN` are Python's
502
+ # non-standard extension literals, not JSON (tan-cli#387). See that
503
+ # function for why this is a projection and not `allow_nan=False`.
504
+ #
505
+ # `_scrub_lone_surrogates`: `ensure_ascii=False` writes a lone
506
+ # surrogate straight through into the `str` this returns, and
507
+ # nothing downstream could recover from that -- the encode fails at
508
+ # `emit()`'s `print(text)`, one call AFTER this function has already
509
+ # reported success, so the `except` below (whose own comment says
510
+ # "no payload may ever crash stdout") never sees it and the process
511
+ # dies with ZERO bytes on stdout (tan-cli#491). Scrubbed HERE, on
512
+ # the finished document, so `to_json()` -- and therefore
513
+ # `tan.cli._usage_error_envelope`'s own `print` -- is covered by the
514
+ # same call.
515
+ return (
516
+ _scrub_lone_surrogates(
517
+ json.dumps(
518
+ json_safe_floats(self._as_dict()),
519
+ separators=(",", ":"),
520
+ ensure_ascii=False,
521
+ )
522
+ ),
523
+ self.exit_code,
524
+ )
525
+ except Exception as err: # noqa: BLE001 -- no payload may ever crash stdout
526
+ fallback_code = int(ExitCode.INTERNAL_FAILURE)
527
+ # The WHOLE fallback, construction included, sits inside the guard
528
+ # below -- not just its `json.dumps`. Building it runs payload code
529
+ # in three places, and every one of them can raise: `SdkInfo.
530
+ # as_dict()` calls `self.root.replace(...)` (an `AttributeError` for
531
+ # a non-`str` root), the f-string below calls `err.__str__`, and
532
+ # `json.dumps` then re-encodes `project`/`sdk` verbatim. With only
533
+ # the `dumps` wrapped, the first two escaped `_serialise` entirely
534
+ # -- past `emit()`, past `to_json()`, out to a traceback and zero
535
+ # bytes on stdout, which is the exact shape tan-cli#491 is about.
536
+ try:
537
+ fallback = {
538
+ "command": self.command,
539
+ "ok": False,
540
+ "exitCode": fallback_code,
541
+ "project": self.project.as_dict(),
542
+ }
543
+ if self.sdk is not None:
544
+ fallback["sdk"] = self.sdk.as_dict()
545
+ fallback["data"] = None
546
+ fallback["issues"] = [
547
+ Issue(
548
+ "envelope.serialize-failed",
549
+ "error",
550
+ f"failed to serialize command output: {err}",
551
+ ).as_dict()
552
+ ]
553
+ # Scrubbed on this arm too: `self.project.as_dict()` is carried
554
+ # into the fallback verbatim, and `err` itself stringifies
555
+ # whatever the failed payload held -- either can carry the same
556
+ # lone surrogate, so the fallback that exists to keep stdout
557
+ # alive must not be the thing that kills it.
558
+ return (
559
+ _scrub_lone_surrogates(
560
+ json.dumps(fallback, separators=(",", ":"), ensure_ascii=False)
561
+ ),
562
+ fallback_code,
563
+ )
564
+ except Exception as fallback_err: # noqa: BLE001 -- nor may THIS arm crash stdout
565
+ # Keeping this here (rather than only at `emit`) is what makes
566
+ # `to_json()` total as well -- and `to_json()` is what
567
+ # `tan.cli`'s `_usage_error_envelope`/`_interrupted_envelope`
568
+ # print. See `_last_resort_document`.
569
+ return _last_resort_document(self.command, fallback_err), fallback_code
570
+
571
+
572
+ #: Whether this process has already written its one envelope to stdout.
573
+ #: Process-global because the thing it guards is process-global: stdout.
574
+ _emitted = False
575
+
576
+ #: The exit code the LAST-emitted envelope's own JSON actually reports --
577
+ #: `None` until `emit()` runs once. See `envelope_emitted_exit_code()`.
578
+ _emitted_exit_code: int | None = None
579
+
580
+ #: Why `emit()`'s guard below catches `(ValueError, TypeError)` and not
581
+ #: `Exception` (tan-cli#491). Those are the ENCODE class: `UnicodeEncodeError`
582
+ #: is a `UnicodeError` is a `ValueError`, and a codec handed something it
583
+ #: cannot accept raises `TypeError`. They fire BEFORE any byte leaves the
584
+ #: stream, because `TextIOWrapper.write` encodes the whole string before
585
+ #: writing any of it -- measured on an ascii-encoded stream, the buffer is
586
+ #: empty after the raise and the ASCII document then lands as the only thing on
587
+ #: it. Recovering there is safe: stdout is still empty.
588
+ #:
589
+ #: An `OSError` is deliberately NOT caught. Measured on a
590
+ #: `TextIOWrapper`->`BufferedWriter(4096)`->raw stream that accepts one block
591
+ #: and then fails, given a 20 KB envelope: 4096 bytes of a TRUNCATED first
592
+ #: document are already on the wire. Appending the last-resort document to that
593
+ #: would report a clean `envelope.serialize-failed` over stdout no consumer can
594
+ #: parse -- worse than the raise, which at least claims nothing. So an I/O
595
+ #: failure propagates exactly as it did before this guard existed, and
596
+ #: `_emitted` stays `False`.
597
+
598
+
599
+ def emit(envelope: Envelope) -> int:
600
+ """Write THE envelope to stdout, record that it went out, and return the
601
+ exit code that JSON just reported.
602
+
603
+ A command signals failure by exiting non-zero, and `tan.cli.main` wraps
604
+ the whole dispatch to add a `cli.parse-error` envelope when a `--format
605
+ json` run exits non-zero with nothing on stdout -- the Click-usage-error
606
+ path, which never reaches a command at all. Without this flag that
607
+ fallback also fires after a command that ALREADY printed its own
608
+ envelope, putting two JSON documents on stdout: valid JSON lines, and a
609
+ consumer that parses stdout whole gets neither.
610
+
611
+ The returned code (also stashed in `_emitted_exit_code`, read back by
612
+ `envelope_emitted_exit_code()`) is `envelope.exit_code` UNLESS
613
+ serialisation itself fell back to `envelope.serialize-failed` -- in which
614
+ case it is the fallback's `ExitCode.INTERNAL_FAILURE` (5), not the
615
+ command's original code. tan-cli#327: a caller that already committed to
616
+ its own `typer.Exit(<original code>)` before calling this needs a way to
617
+ learn the fallback happened; `tan.cli.main`'s process boundary is that
618
+ caller, so the wire invariant `process exit code == envelope.exitCode`
619
+ holds even when serialisation fails.
620
+
621
+ Same for the WRITE failing (tan-cli#491): the last-resort document below
622
+ reports 5 too, and this returns 5, so a command that had already committed
623
+ to `typer.Exit(0)` still exits 5 to match the JSON stdout actually carries.
624
+ """
625
+ global _emitted, _emitted_exit_code
626
+ try:
627
+ text, exit_code = envelope._serialise()
628
+ print(text)
629
+ except (ValueError, TypeError) as err: # the ENCODE class only -- see above
630
+ exit_code = int(ExitCode.INTERNAL_FAILURE)
631
+ print(_last_resort_document(envelope.command, err))
632
+ _emitted = True
633
+ _emitted_exit_code = exit_code
634
+ return exit_code
635
+
636
+
637
+ def envelope_emitted() -> bool:
638
+ return _emitted
639
+
640
+
641
+ def envelope_emitted_exit_code() -> int | None:
642
+ """The exit code the one emitted envelope's JSON reports, or `None` if
643
+ nothing has been emitted yet this process. `tan.cli.main` reads this at
644
+ the process boundary to catch a serialize-failure fallback that changed
645
+ the reported code out from under a command's own `typer.Exit` (tan-cli#327)
646
+ -- carried directly from `emit()`, never re-derived by parsing stdout back."""
647
+ return _emitted_exit_code
tan/exit_codes.py ADDED
@@ -0,0 +1,12 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Stable process exit codes, fixed by the CLI output contract."""
3
+ from enum import IntEnum
4
+
5
+
6
+ class ExitCode(IntEnum):
7
+ SUCCESS = 0
8
+ RUNTIME_FAILURE = 1
9
+ VALIDATION_FAILURE = 2
10
+ WRITE_FAILURE = 3
11
+ DOCTOR_FAILURE = 4
12
+ INTERNAL_FAILURE = 5