tan-cli 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (330) hide show
  1. tan/__init__.py +0 -0
  2. tan/__main__.py +120 -0
  3. tan/cli.py +939 -0
  4. tan/command_table.py +265 -0
  5. tan/commands/__init__.py +8 -0
  6. tan/commands/bootstrap_cmd.py +4856 -0
  7. tan/commands/bootstrap_patches.py +164 -0
  8. tan/commands/bootstrap_toolchain_fetch.py +155 -0
  9. tan/commands/bootstrap_toolchain_pin.py +36 -0
  10. tan/commands/build/__init__.py +1 -0
  11. tan/commands/build/configure_inputs.py +277 -0
  12. tan/commands/build/execute.py +1852 -0
  13. tan/commands/build/host_python.py +59 -0
  14. tan/commands/build/link_stale.py +78 -0
  15. tan/commands/build/manifest.py +428 -0
  16. tan/commands/build/materialise.py +81 -0
  17. tan/commands/build/token_substitution.py +381 -0
  18. tan/commands/build/toolchain.py +482 -0
  19. tan/commands/build/user_defines_wipe.py +115 -0
  20. tan/commands/build/workspace_patches.py +65 -0
  21. tan/commands/build_cmd.py +2236 -0
  22. tan/commands/build_output.py +331 -0
  23. tan/commands/clean_cmd.py +1183 -0
  24. tan/commands/completion_cmd.py +786 -0
  25. tan/commands/debug_config_cmd.py +2425 -0
  26. tan/commands/diff_cmd.py +940 -0
  27. tan/commands/doctor_cmd.py +4767 -0
  28. tan/commands/examples_cmd.py +644 -0
  29. tan/commands/explain_cmd.py +1319 -0
  30. tan/commands/faultdecode_cmd.py +931 -0
  31. tan/commands/flash_atoc_guard.py +240 -0
  32. tan/commands/flash_cmd.py +5731 -0
  33. tan/commands/flash_mram_guard.py +150 -0
  34. tan/commands/flash_ram.py +578 -0
  35. tan/commands/flash_raw.py +583 -0
  36. tan/commands/generate_cmd.py +1456 -0
  37. tan/commands/image_cmd.py +753 -0
  38. tan/commands/init_cmd.py +1740 -0
  39. tan/commands/inspect_cmd.py +377 -0
  40. tan/commands/kconfig_cmd.py +734 -0
  41. tan/commands/model_cmd.py +1514 -0
  42. tan/commands/model_device_cmd.py +351 -0
  43. tan/commands/model_device_live.py +234 -0
  44. tan/commands/model_host_cmd.py +430 -0
  45. tan/commands/model_zoo_cmd.py +363 -0
  46. tan/commands/monitor_actions.py +125 -0
  47. tan/commands/monitor_cmd.py +626 -0
  48. tan/commands/monitor_session.py +505 -0
  49. tan/commands/new_som_cmd.py +1522 -0
  50. tan/commands/pinmux_cmd.py +568 -0
  51. tan/commands/presets_cmd.py +1123 -0
  52. tan/commands/probe_cmd.py +421 -0
  53. tan/commands/reset_cmd.py +354 -0
  54. tan/commands/reset_confirm.py +168 -0
  55. tan/commands/run_cmd.py +637 -0
  56. tan/commands/scaffold_cmd.py +490 -0
  57. tan/commands/sdk_cmd.py +1723 -0
  58. tan/commands/size_cmd.py +1020 -0
  59. tan/commands/support_bundle_cmd.py +1073 -0
  60. tan/commands/trace_cmd.py +341 -0
  61. tan/commands/validate_cmd.py +2009 -0
  62. tan/commands/west_forward_cmd.py +736 -0
  63. tan/commands/workspace_patch_check.py +296 -0
  64. tan/core/__init__.py +1 -0
  65. tan/core/alp_conf_pregen.py +100 -0
  66. tan/core/artifact_provenance.py +240 -0
  67. tan/core/atoc_guard.py +276 -0
  68. tan/core/atoc_guard_messages.py +232 -0
  69. tan/core/atoc_replacement.py +118 -0
  70. tan/core/atomic_write.py +178 -0
  71. tan/core/board_context.py +351 -0
  72. tan/core/board_diagnostic.py +111 -0
  73. tan/core/board_files.py +221 -0
  74. tan/core/board_validator.py +542 -0
  75. tan/core/board_validator_compat.py +196 -0
  76. tan/core/board_validator_run.py +129 -0
  77. tan/core/board_validator_schema.py +186 -0
  78. tan/core/board_validator_skew.py +46 -0
  79. tan/core/board_yaml_edit.py +295 -0
  80. tan/core/board_yaml_pos.py +118 -0
  81. tan/core/bootstrap.py +2572 -0
  82. tan/core/build_plan.py +625 -0
  83. tan/core/consent.py +104 -0
  84. tan/core/console_filter.py +245 -0
  85. tan/core/debug_launch.py +1803 -0
  86. tan/core/devicetree_lint.py +168 -0
  87. tan/core/dir_removal.py +229 -0
  88. tan/core/doctor_git.py +172 -0
  89. tan/core/doctor_libraries.py +253 -0
  90. tan/core/doctor_render.py +185 -0
  91. tan/core/doctor_scope.py +66 -0
  92. tan/core/doctor_setools.py +237 -0
  93. tan/core/doctor_stale.py +180 -0
  94. tan/core/document_guards.py +723 -0
  95. tan/core/dp_id.py +97 -0
  96. tan/core/elf_load.py +63 -0
  97. tan/core/error_catalog.py +171 -0
  98. tan/core/example_catalog.py +499 -0
  99. tan/core/example_facets.py +151 -0
  100. tan/core/faultdecode.py +521 -0
  101. tan/core/flash_plan.py +2501 -0
  102. tan/core/flow_d_report.py +381 -0
  103. tan/core/flow_d_uptime.py +164 -0
  104. tan/core/fs_confine.py +130 -0
  105. tan/core/global_flags.py +230 -0
  106. tan/core/host_python.py +210 -0
  107. tan/core/image_bundle.py +247 -0
  108. tan/core/inert.py +147 -0
  109. tan/core/jlink_binary.py +128 -0
  110. tan/core/jlink_probe.py +481 -0
  111. tan/core/jsonc_splice.py +404 -0
  112. tan/core/launch_provenance.py +257 -0
  113. tan/core/link_refusal.py +76 -0
  114. tan/core/metadata_schema.py +376 -0
  115. tan/core/model_check.py +227 -0
  116. tan/core/model_device.py +449 -0
  117. tan/core/model_doctor.py +198 -0
  118. tan/core/model_host.py +48 -0
  119. tan/core/model_list.py +112 -0
  120. tan/core/model_zoo.py +353 -0
  121. tan/core/module_template.py +340 -0
  122. tan/core/mram_link.py +87 -0
  123. tan/core/new_som_ospi.py +94 -0
  124. tan/core/os_class.py +159 -0
  125. tan/core/patch_paths.py +87 -0
  126. tan/core/pending.py +40 -0
  127. tan/core/plain_zephyr_plan.py +138 -0
  128. tan/core/plan_exec.py +373 -0
  129. tan/core/plan_tokens.py +600 -0
  130. tan/core/probe.py +155 -0
  131. tan/core/probe_plan.py +237 -0
  132. tan/core/proxy.py +168 -0
  133. tan/core/publish.py +31 -0
  134. tan/core/python_floor.py +178 -0
  135. tan/core/ram_run.py +694 -0
  136. tan/core/ram_watch.py +231 -0
  137. tan/core/raw_write.py +160 -0
  138. tan/core/reset_plan.py +56 -0
  139. tan/core/run.py +161 -0
  140. tan/core/scaffold.py +2203 -0
  141. tan/core/scaffold_selftest_identity.py +225 -0
  142. tan/core/sdk_default_registry.py +565 -0
  143. tan/core/sdk_discovery.py +996 -0
  144. tan/core/sdk_removal.py +465 -0
  145. tan/core/serial_actions.py +175 -0
  146. tan/core/serial_capture.py +185 -0
  147. tan/core/serial_url.py +45 -0
  148. tan/core/setools.py +608 -0
  149. tan/core/setools_scratch.py +243 -0
  150. tan/core/shapes.py +194 -0
  151. tan/core/size.py +610 -0
  152. tan/core/som_buildability.py +204 -0
  153. tan/core/som_schema_version.py +62 -0
  154. tan/core/subprocess_env.py +201 -0
  155. tan/core/sysbuild_image.py +94 -0
  156. tan/core/system_manifest.py +827 -0
  157. tan/core/text_layout.py +73 -0
  158. tan/core/timestamp.py +182 -0
  159. tan/core/tool_lookup.py +383 -0
  160. tan/core/toolchain_pin.py +117 -0
  161. tan/core/toolchain_provision.py +1224 -0
  162. tan/core/trusted_path.py +134 -0
  163. tan/core/uboot_breakin.py +154 -0
  164. tan/core/uri_reference.py +415 -0
  165. tan/core/user_defines.py +152 -0
  166. tan/core/venv.py +332 -0
  167. tan/core/west_patches.py +143 -0
  168. tan/core/west_workspace_refusal.py +159 -0
  169. tan/core/zephyr_env.py +124 -0
  170. tan/env.py +185 -0
  171. tan/envelope.py +647 -0
  172. tan/exit_codes.py +12 -0
  173. tan/lazy_typer.py +185 -0
  174. tan/model/__init__.py +2 -0
  175. tan/model/_gen_fixture.py +132 -0
  176. tan/model/adapters/__init__.py +116 -0
  177. tan/model/adapters/cpu.py +26 -0
  178. tan/model/adapters/deepx.py +94 -0
  179. tan/model/adapters/drpai.py +242 -0
  180. tan/model/adapters/ethos_u.py +951 -0
  181. tan/model/adapters/executorch.py +55 -0
  182. tan/model/analyze.py +694 -0
  183. tan/model/build.py +304 -0
  184. tan/model/check.py +682 -0
  185. tan/model/manifest.py +625 -0
  186. tan/model/measure.py +216 -0
  187. tan/model/npu_ops_validate.py +130 -0
  188. tan/model/package.py +125 -0
  189. tan/model/perf.py +635 -0
  190. tan/model/perf_apply.py +721 -0
  191. tan/model/prep.py +193 -0
  192. tan/model/sram_fit.py +502 -0
  193. tan/model/targets.py +374 -0
  194. tan/model/tensorio.py +287 -0
  195. tan/net.py +73 -0
  196. tan/output_format.py +213 -0
  197. tan/planner/__init__.py +219 -0
  198. tan/planner/aperture.py +202 -0
  199. tan/planner/buildplan.py +1092 -0
  200. tan/planner/camera_owner.py +192 -0
  201. tan/planner/cameras.py +132 -0
  202. tan/planner/carveout.py +755 -0
  203. tan/planner/cli.py +274 -0
  204. tan/planner/headers.py +315 -0
  205. tan/planner/kconfig.py +2435 -0
  206. tan/planner/kconfig_symbols.py +473 -0
  207. tan/planner/libraries.py +677 -0
  208. tan/planner/link_target.py +237 -0
  209. tan/planner/linux_ownership.py +216 -0
  210. tan/planner/loader.py +1525 -0
  211. tan/planner/manifest.py +116 -0
  212. tan/planner/memory.py +161 -0
  213. tan/planner/memregion.py +56 -0
  214. tan/planner/models.py +533 -0
  215. tan/planner/orchestrator.py +874 -0
  216. tan/planner/ownership.py +203 -0
  217. tan/planner/partition.py +1160 -0
  218. tan/planner/paths.py +32 -0
  219. tan/planner/plain_slice.py +139 -0
  220. tan/planner/project_emit/__init__.py +60 -0
  221. tan/planner/project_emit/bom_netlist.py +375 -0
  222. tan/planner/project_emit/dts.py +580 -0
  223. tan/planner/project_emit/hw_info.py +221 -0
  224. tan/planner/project_emit/native_sim.py +136 -0
  225. tan/planner/project_emit/west_libs.py +213 -0
  226. tan/planner/project_loader.py +264 -0
  227. tan/planner/sdk_capability.py +203 -0
  228. tan/planner/sdk_compat.py +395 -0
  229. tan/planner/secure.py +350 -0
  230. tan/planner/slugs.py +394 -0
  231. tan/planner/som_metadata.py +325 -0
  232. tan/planner/strict_loaders.py +131 -0
  233. tan/planner/template.py +1037 -0
  234. tan/planner/template_pins.py +535 -0
  235. tan/planner/template_rewrite.py +714 -0
  236. tan/planner/topology.py +208 -0
  237. tan/planner/validate.py +365 -0
  238. tan/planner/whole_device_alias.py +67 -0
  239. tan/planner/zephyr_board.py +3510 -0
  240. tan/planner_cli.py +97 -0
  241. tan/planner_emit.py +617 -0
  242. tan/planner_root.py +119 -0
  243. tan/soc_ref.py +58 -0
  244. tan/templates/__init__.py +34 -0
  245. tan/templates/vendored/MANIFEST.md +1862 -0
  246. tan/templates/vendored/diagnostics/E1M-AEN801/CMakeLists.txt +8 -0
  247. tan/templates/vendored/diagnostics/E1M-AEN801/README.md +112 -0
  248. tan/templates/vendored/diagnostics/E1M-AEN801/board.yaml +38 -0
  249. tan/templates/vendored/diagnostics/E1M-AEN801/boards/native_sim_native_64.conf +11 -0
  250. tan/templates/vendored/diagnostics/E1M-AEN801/boards/native_sim_native_64.overlay +29 -0
  251. tan/templates/vendored/diagnostics/E1M-AEN801/prj.conf +5 -0
  252. tan/templates/vendored/diagnostics/E1M-AEN801/src/main.c +266 -0
  253. tan/templates/vendored/diagnostics/E1M-AEN801/testcase.yaml +53 -0
  254. tan/templates/vendored/diagnostics/E1M-V2N101/CMakeLists.txt +8 -0
  255. tan/templates/vendored/diagnostics/E1M-V2N101/README.md +112 -0
  256. tan/templates/vendored/diagnostics/E1M-V2N101/board.yaml +38 -0
  257. tan/templates/vendored/diagnostics/E1M-V2N101/boards/native_sim_native_64.conf +11 -0
  258. tan/templates/vendored/diagnostics/E1M-V2N101/boards/native_sim_native_64.overlay +29 -0
  259. tan/templates/vendored/diagnostics/E1M-V2N101/prj.conf +5 -0
  260. tan/templates/vendored/diagnostics/E1M-V2N101/src/main.c +266 -0
  261. tan/templates/vendored/diagnostics/E1M-V2N101/testcase.yaml +53 -0
  262. tan/templates/vendored/edge-ai/E1M-AEN801/CMakeLists.txt +10 -0
  263. tan/templates/vendored/edge-ai/E1M-AEN801/README.md +75 -0
  264. tan/templates/vendored/edge-ai/E1M-AEN801/board.yaml +38 -0
  265. tan/templates/vendored/edge-ai/E1M-AEN801/prj.conf +8 -0
  266. tan/templates/vendored/edge-ai/E1M-AEN801/src/cold_chain.c +371 -0
  267. tan/templates/vendored/edge-ai/E1M-AEN801/src/cold_chain.h +178 -0
  268. tan/templates/vendored/edge-ai/E1M-AEN801/src/main.c +373 -0
  269. tan/templates/vendored/edge-ai/E1M-AEN801/testcase.yaml +62 -0
  270. tan/templates/vendored/edge-ai/E1M-V2N101/CMakeLists.txt +10 -0
  271. tan/templates/vendored/edge-ai/E1M-V2N101/README.md +75 -0
  272. tan/templates/vendored/edge-ai/E1M-V2N101/board.yaml +37 -0
  273. tan/templates/vendored/edge-ai/E1M-V2N101/prj.conf +8 -0
  274. tan/templates/vendored/edge-ai/E1M-V2N101/src/cold_chain.c +371 -0
  275. tan/templates/vendored/edge-ai/E1M-V2N101/src/cold_chain.h +178 -0
  276. tan/templates/vendored/edge-ai/E1M-V2N101/src/main.c +373 -0
  277. tan/templates/vendored/edge-ai/E1M-V2N101/testcase.yaml +62 -0
  278. tan/templates/vendored/iot/E1M-AEN801/CMakeLists.txt +14 -0
  279. tan/templates/vendored/iot/E1M-AEN801/README.md +103 -0
  280. tan/templates/vendored/iot/E1M-AEN801/board.yaml +69 -0
  281. tan/templates/vendored/iot/E1M-AEN801/prj.conf +15 -0
  282. tan/templates/vendored/iot/E1M-AEN801/src/cc3501e_bridge.c +196 -0
  283. tan/templates/vendored/iot/E1M-AEN801/src/cc3501e_bridge.h +173 -0
  284. tan/templates/vendored/iot/E1M-AEN801/src/main.c +262 -0
  285. tan/templates/vendored/iot/E1M-AEN801/testcase.yaml +68 -0
  286. tan/templates/vendored/minimal/E1M-AEN801/CMakeLists.txt +8 -0
  287. tan/templates/vendored/minimal/E1M-AEN801/README.md +108 -0
  288. tan/templates/vendored/minimal/E1M-AEN801/board.yaml +32 -0
  289. tan/templates/vendored/minimal/E1M-AEN801/prj.conf +8 -0
  290. tan/templates/vendored/minimal/E1M-AEN801/src/main.c +111 -0
  291. tan/templates/vendored/minimal/E1M-AEN801/testcase.yaml +49 -0
  292. tan/templates/vendored/minimal/E1M-V2N101/CMakeLists.txt +8 -0
  293. tan/templates/vendored/minimal/E1M-V2N101/README.md +108 -0
  294. tan/templates/vendored/minimal/E1M-V2N101/board.yaml +32 -0
  295. tan/templates/vendored/minimal/E1M-V2N101/prj.conf +8 -0
  296. tan/templates/vendored/minimal/E1M-V2N101/src/main.c +111 -0
  297. tan/templates/vendored/minimal/E1M-V2N101/testcase.yaml +49 -0
  298. tan/templates/vendored/multicore-mailbox/E1M-AEN801/CMakeLists.txt +8 -0
  299. tan/templates/vendored/multicore-mailbox/E1M-AEN801/README.md +120 -0
  300. tan/templates/vendored/multicore-mailbox/E1M-AEN801/board.yaml +57 -0
  301. tan/templates/vendored/multicore-mailbox/E1M-AEN801/boards/native_sim_native_64.overlay +40 -0
  302. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/CMakeLists.txt +13 -0
  303. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/main.c +171 -0
  304. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/prj.conf +17 -0
  305. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/testcase.yaml +23 -0
  306. tan/templates/vendored/multicore-mailbox/E1M-AEN801/prj.conf +22 -0
  307. tan/templates/vendored/multicore-mailbox/E1M-AEN801/src/main.c +185 -0
  308. tan/templates/vendored/multicore-mailbox/E1M-AEN801/testcase.yaml +26 -0
  309. tan/templates/vendored/sensor/E1M-AEN801/CMakeLists.txt +8 -0
  310. tan/templates/vendored/sensor/E1M-AEN801/README.md +112 -0
  311. tan/templates/vendored/sensor/E1M-AEN801/board.yaml +57 -0
  312. tan/templates/vendored/sensor/E1M-AEN801/boards/native_sim_native_64.conf +10 -0
  313. tan/templates/vendored/sensor/E1M-AEN801/boards/native_sim_native_64.overlay +28 -0
  314. tan/templates/vendored/sensor/E1M-AEN801/prj.conf +5 -0
  315. tan/templates/vendored/sensor/E1M-AEN801/src/main.c +186 -0
  316. tan/templates/vendored/sensor/E1M-AEN801/testcase.yaml +52 -0
  317. tan/templates/vendored/sensor/E1M-V2N101/CMakeLists.txt +8 -0
  318. tan/templates/vendored/sensor/E1M-V2N101/README.md +112 -0
  319. tan/templates/vendored/sensor/E1M-V2N101/board.yaml +57 -0
  320. tan/templates/vendored/sensor/E1M-V2N101/boards/native_sim_native_64.conf +10 -0
  321. tan/templates/vendored/sensor/E1M-V2N101/boards/native_sim_native_64.overlay +28 -0
  322. tan/templates/vendored/sensor/E1M-V2N101/prj.conf +5 -0
  323. tan/templates/vendored/sensor/E1M-V2N101/src/main.c +186 -0
  324. tan/templates/vendored/sensor/E1M-V2N101/testcase.yaml +52 -0
  325. tan/version.py +102 -0
  326. tan_cli-0.7.0.dist-info/METADATA +125 -0
  327. tan_cli-0.7.0.dist-info/RECORD +330 -0
  328. tan_cli-0.7.0.dist-info/WHEEL +5 -0
  329. tan_cli-0.7.0.dist-info/entry_points.txt +2 -0
  330. tan_cli-0.7.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,465 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """`tan sdk remove` (tan-cli#790) -- the pure/near-pure half: resolving
3
+ `<version|path>` against `--destination`, sizing a tree before it is gone,
4
+ and performing the removal itself with the same read-only-retry hardening
5
+ `tan clean` already carries (`tan.core.dir_removal`, tan-cli#790 same
6
+ change), plus this verb's OWN two failure classes clean's caller never had
7
+ to tell apart -- see [`classify_removal_error`].
8
+
9
+ **Why this exists at all, not just a hand-deleted tree.** alp-sdk-vscode's
10
+ own removal was one line, `fs.rmSync(target, { recursive: true, force: true
11
+ })` -- `force` swallows only `ENOENT`, so a locked file (a terminal cwd'd
12
+ into the tree, a build mid-flight, an indexer) or a permission it cannot
13
+ clear surfaces as a raw, unclassified `EPERM`/`EBUSY` with no verdict a
14
+ consumer can act on. Every OTHER consumer of a `tan`-managed SDK cache (a CI
15
+ script rotating versions, a clean-host reset) was left to reimplement the
16
+ identical judgement call by hand. This module is the ONE place that makes
17
+ it.
18
+
19
+ **`tan.core`, not `tan.commands.sdk_cmd`.** Keeps the command file a thin
20
+ orchestrator (arg parsing, the resolution-ladder call, the envelope) over
21
+ logic that is unit-testable without spawning a subprocess, the same split
22
+ `tan.core.sdk_discovery`'s own module docstring argues for. Filesystem IO
23
+ lives here anyway -- like `tan.core.dir_removal`'s own removal primitives,
24
+ and like `sdk_discovery`'s pointer/registry reads, "no direct command
25
+ import" is the invariant this module keeps, not "no IO at all".
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import errno
30
+ import os
31
+ from dataclasses import dataclass
32
+ from pathlib import Path
33
+
34
+ from tan.core.dir_removal import os_error_text, remove_dir
35
+ from tan.core.sdk_default_registry import is_absolute_either_platform
36
+
37
+
38
+ def looks_like_path(raw: str) -> bool:
39
+ """Whether `raw` -- the caller's `<version|path>` argument, unstripped of
40
+ its own meaning by this point -- names an explicit filesystem location
41
+ rather than a bare cache-relative version name.
42
+
43
+ A path SEPARATOR (either flavour, so a POSIX-spelled path is still
44
+ recognised when typed on Windows and vice versa -- the same
45
+ either-platform tolerance `sdk_default_registry.is_absolute_either_platform`
46
+ already applies to a value this same registry stores), an absolute path on
47
+ THIS platform, or the two relative-navigation tokens `.`/`..` (which
48
+ contain no separator yet plainly do not name a released version) all
49
+ count. Everything else -- `v0.15.0`, `0.15.0-rc1`, a bare filename with no
50
+ separator -- is a version name, looked up under `--destination`.
51
+ """
52
+ return (
53
+ "/" in raw
54
+ or "\\" in raw
55
+ or raw in (".", "..")
56
+ or Path(raw).is_absolute()
57
+ )
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class RemovalTarget:
62
+ """What `<version|path>` resolved to, plus the two facts the caller needs
63
+ to police it: whether it was a bare version name (always inside
64
+ `destination` by construction, so the outside-root refusal can never
65
+ apply to it) and the cache root it was resolved against."""
66
+
67
+ target: Path
68
+ destination: Path
69
+ is_named_version: bool
70
+
71
+
72
+ def resolve_removal_target(raw_arg: str, destination: Path) -> RemovalTarget:
73
+ """`raw_arg` (already stripped and confirmed non-empty by the caller)
74
+ resolved against `destination` -- the `--destination` value, or
75
+ `sdk_cmd._default_cache_root()`'s default, either way the caller's to
76
+ compute and pass in, since this module has no opinion on `~/.alp`
77
+ resolution (the same division `sdk_default_registry` already draws
78
+ against `sdk_discovery._home_alp_dir`).
79
+
80
+ A bare version name is joined onto `destination` UNCONDITIONALLY -- never
81
+ checked against what is actually installed there, because "does this
82
+ version exist" is exactly the idempotent-absence question the caller
83
+ answers next, not this one.
84
+ """
85
+ if looks_like_path(raw_arg):
86
+ return RemovalTarget(Path(raw_arg), destination, False)
87
+ return RemovalTarget(destination / raw_arg, destination, True)
88
+
89
+
90
+ def is_outside_cache_root(target: Path, destination: Path) -> bool:
91
+ """Whether `target` sits outside `destination` -- the tan-cli#790 footgun
92
+ guard: "refuse anything outside the cache root unless an explicit path is
93
+ given and confirmed by --force". A bare version name can never trip this
94
+ (`resolve_removal_target` joins it onto `destination` itself), so callers
95
+ only need to ask this for the explicit-path arm.
96
+
97
+ Lexical containment (`os.path.abspath`, not `Path.resolve()`) for the same
98
+ reason `sdk_discovery._abs_posix` gives: cwd-anchored and stable for a
99
+ path that does not exist yet, which a removal target -- by definition,
100
+ once it is gone -- must still compare correctly against on a re-run.
101
+ `Path.is_relative_to` never touches the filesystem either (a PurePath-only
102
+ comparison of the already-lexically-normalised `abspath` strings), so a
103
+ Windows cross-drive pair (`C:\\x` vs `D:\\y`) answers plainly `False` --
104
+ "not relative to" -- rather than raising, the way `os.path.commonpath`
105
+ would on the same pair.
106
+ """
107
+ root = Path(os.path.abspath(str(destination)))
108
+ candidate = Path(os.path.abspath(str(target)))
109
+ return not (candidate == root or candidate.is_relative_to(root))
110
+
111
+
112
+ def is_cache_root_itself(target: Path, destination: Path) -> bool:
113
+ """Whether `target` resolves to `destination` EXACTLY -- the case
114
+ `is_outside_cache_root` treats as fine (`candidate == root` is
115
+ deliberately not "outside") but that is in fact the single most
116
+ destructive target this command can be pointed at: every install under
117
+ the cache root at once, not one of them. Found live, by measurement, not
118
+ by reading: `tan sdk remove .` from inside an otherwise-empty cwd, or
119
+ `tan sdk remove <dest> --destination <dest>`, deleted a two-version cache
120
+ root outright, `ok: true`, no `--force` required, because `--destination`
121
+ itself is never checked against `_load_bearing_reasons` (that ladder only
122
+ ever names a specific VERSION subdirectory, never the root that holds
123
+ them) and `is_outside_cache_root` explicitly exempts the equal case so a
124
+ caller CAN still name the root on purpose elsewhere.
125
+
126
+ Same lexical-abspath comparison as `is_outside_cache_root`, for the same
127
+ reason (stable for a target that will not exist once removed).
128
+ """
129
+ root = Path(os.path.abspath(str(destination)))
130
+ candidate = Path(os.path.abspath(str(target)))
131
+ return candidate == root
132
+
133
+
134
+ # ── would removing THIS take out THAT? (tan-cli#1053) ───────────────────────
135
+ #
136
+ # Every load-bearing `sdk remove` refusal asks one question -- "would deleting
137
+ # the target destroy this other thing?" -- and through `dev` asked it four
138
+ # separate times with a plain string `==` between two spellings. Every one of
139
+ # those failed in the UNSAFE direction: a miss does not merely skip a tidy-up,
140
+ # it means the refusal never fires, `remove` proceeds without `--force`, and
141
+ # the install another project still points at is silently orphaned -- exactly
142
+ # the outcome tan-cli#790's first design bar exists to prevent.
143
+ #
144
+ # The question is ASYMMETRIC, which is why the helper below is not called
145
+ # "same directory" and does not take two interchangeable operands. `remove_dir`
146
+ # unlinks a symlink it is handed and never follows it, so removing a LINK
147
+ # destroys nothing behind the link, while removing the real directory a link
148
+ # points AT does orphan whoever resolves through that link. One direction is
149
+ # load-bearing and the other is not, and a symmetric predicate gets one of them
150
+ # wrong -- measured, in review, on the first version of this code (see the
151
+ # `islink` arm below).
152
+ #
153
+ # THREE ARMS, in this order.
154
+ #
155
+ # 1. `os.path.normcase` -- the stdlib's own platform-aware spelling fold. On
156
+ # Windows (`ntpath`) it lowercases AND flips separators, so
157
+ # `C:/Users/Me/sdk` and `c:/users/me/sdk` compare equal there; on POSIX
158
+ # (`posixpath`) it is the IDENTITY, so `/home/me/sdk` and `/home/Me/sdk`
159
+ # stay the two genuinely different directories they really are. That is
160
+ # why the fold belongs here and NOT inside
161
+ # `sdk_default_registry.normalized_sdk_path`, whose own docstring makes
162
+ # the same argument: that helper folds SEPARATORS, and a case fold there
163
+ # would simply be wrong on POSIX. Checked FIRST and unconditionally, so
164
+ # naming the identical path always answers True -- including when that
165
+ # path is itself a link, which the next arm would otherwise veto.
166
+ # 2. `os.path.islink(target)` -- switch to `os.path.samestat` over two
167
+ # `os.lstat`s, which follows NEITHER final component. Removing a symlink
168
+ # unlinks the link and leaves everything behind it exactly where it was
169
+ # (`dir_removal.remove_dir`, and `compute_tree_bytes` charging the link's
170
+ # own `lstat` size, are already built on that same fact), so the only
171
+ # thing such a removal CAN take out is that same link under another
172
+ # spelling. Without this arm the `samefile` arm below fires in the wrong
173
+ # direction: measured on the first version of this change, a cache
174
+ # holding `v0.19.0` plus `current -> v0.19.0` with the workspace pinned
175
+ # at the REAL directory refused `tan sdk remove <cache>/current` as "the
176
+ # active alp-sdk for this workspace", and reported `data.wasActive:
177
+ # true`, in the very same envelope whose `resolvesToAfter` said the
178
+ # workspace still resolved at `projectPin` to a live SDK. `--force` then
179
+ # proved the refusal empty: the link was unlinked and both the install
180
+ # and the pin survived untouched.
181
+ #
182
+ # A blanket `return False` here was the SECOND version, and it was too
183
+ # wide in the other direction (review, round 2): with `cache/current ->
184
+ # v0.19.0` and `alias -> cache`, a workspace pinned at
185
+ # `<T>/alias/current` -- the SAME link, spelled through the alias -- lost
186
+ # it to `tan sdk remove <cache>/current` with no `--force` at all, going
187
+ # `projectPin` -> `none`. `samestat` over `lstat` keeps the harmless
188
+ # case allowed and refuses that one: two spellings of one LINK share an
189
+ # inode, a link and its own target do not.
190
+ # 3. `os.path.samefile` -- consulted only when the lexical arm missed and
191
+ # the target is not a link. `normcase` alone is NOT the fix, because it
192
+ # is the identity on darwin too while macOS's DEFAULT APFS volume is
193
+ # case-INSENSITIVE: the maintainer laptop this was measured on would
194
+ # still have orphaned the project. A blanket "fold on darwin" would be
195
+ # wrong in the other direction (macOS can be formatted case-sensitively),
196
+ # and probing the volume by writing a differently-cased test file mutates
197
+ # a filesystem this command is about to delete from, needs write
198
+ # permission it may not have, and cannot answer for a path that is
199
+ # already gone. `samefile` IS that probe, narrowed to exactly the pair
200
+ # being compared and mutating nothing: identical `st_dev`/`st_ino` is the
201
+ # filesystem's own answer, correct on a case-sensitive volume (two real
202
+ # directories have two inodes) and on a case-insensitive one alike.
203
+ #
204
+ # WHAT IT STILL GETS WRONG, stated rather than discovered later:
205
+ #
206
+ # * `samefile` RAISES for a path that does not exist -- and a removal target
207
+ # legitimately stops existing partway through this command -- so that arm
208
+ # degrades to the lexical answer rather than propagating. On a
209
+ # case-insensitive volume a comparison against an ALREADY-ABSENT path
210
+ # still misses. Narrow by construction: `_run_remove` only reaches its
211
+ # load-bearing ladder after `os.path.lexists(target)`, and a counterpart
212
+ # that no longer exists is not load-bearing -- removing the target cannot
213
+ # orphan it any further than it already is.
214
+ # * The `samefile` arm is skipped entirely unless BOTH sides are absolute
215
+ # (under either platform's flavour, since the registry it compares values
216
+ # from is one file shared by every `tan` on the host). A relative stored
217
+ # value would otherwise be anchored to the REMOVING process's cwd and
218
+ # invent a match its writer never wrote -- the precise hazard
219
+ # `normalized_sdk_path` refuses `_abs_posix` for.
220
+ # * The `islink` veto looks only at the target's FINAL component, exactly as
221
+ # `remove_dir` does. An INTERMEDIATE link in the target's path
222
+ # (`<cache-link>/v0.19.0`, where `cache-link -> cache`) is therefore not
223
+ # vetoed, and correctly so: removing that path really does delete the real
224
+ # `v0.19.0` directory and really would orphan whoever points at it.
225
+ # * `samefile` still FOLLOWS symlinks on the CANDIDATE side. An install
226
+ # whose pin/registry entry is spelled through a link now matches the real
227
+ # directory being removed -- a widening in the SAFE direction (removing it
228
+ # really would orphan that workspace), but a behaviour change: `--force`
229
+ # is required there where a plain `==` let it through.
230
+ # * `ntpath.normcase` folds case unconditionally, including inside a
231
+ # directory carrying Windows' per-directory case-sensitivity flag
232
+ # (`fsutil file setCaseSensitiveInfo`). Two genuinely distinct
233
+ # directories there compare equal and the removal refuses -- an
234
+ # over-refusal, recoverable with `--force`, taken over the silent-orphan
235
+ # alternative.
236
+
237
+
238
+ def removal_would_take_out(candidate: str, target: str) -> bool:
239
+ """Whether removing `target` would destroy `candidate` -- the single
240
+ comparison every load-bearing `sdk remove` refusal makes, replacing five
241
+ independent `==`. ASYMMETRIC on purpose: `removal_would_take_out(link,
242
+ real_dir)` is True while `removal_would_take_out(real_dir, link)` is
243
+ False, because unlinking a link destroys nothing behind it -- while two
244
+ spellings of the SAME link are still one thing, and still True. See the
245
+ section banner above for the three arms and for what this still gets
246
+ wrong.
247
+ """
248
+ if os.path.normcase(candidate) == os.path.normcase(target):
249
+ return True
250
+ if not (is_absolute_either_platform(candidate) and is_absolute_either_platform(target)):
251
+ return False
252
+ if _nested_inside(candidate, target):
253
+ # Removing a directory removes everything under it: a candidate that
254
+ # LIVES INSIDE the target (`sdk remove sdk-cache` while
255
+ # `sdk-cache/v0.19.0` is pinned) is destroyed with it.
256
+ return True
257
+ try:
258
+ if os.path.islink(target):
259
+ # Removing a link unlinks the LINK, so the only thing this
260
+ # removal can take out is that same link under another spelling
261
+ # -- `lstat` on BOTH sides, which follows neither final
262
+ # component, and never `samefile`, which follows both.
263
+ return os.path.samestat(os.lstat(candidate), os.lstat(target))
264
+ return os.path.samefile(candidate, target)
265
+ except (OSError, ValueError):
266
+ # Missing, unreadable, or containing a NUL -- the same best-effort
267
+ # degrade every other filesystem read in this module applies. `False`
268
+ # here is the lexical arm's answer, already computed above.
269
+ return False
270
+
271
+
272
+ def _lex(path: str) -> str:
273
+ return os.path.normcase(path).replace("\\", "/").rstrip("/")
274
+
275
+
276
+ def _lexically_inside(child: str, parent: str) -> bool:
277
+ """`child` is strictly below `parent`, compared on whole components."""
278
+ c, p = _lex(child), _lex(parent)
279
+ return bool(p) and c != p and c.startswith(p + "/")
280
+
281
+
282
+ def _nested_inside(child: str, parent: str) -> bool:
283
+ """`child` is strictly below the real directory `parent` -- lexically, or
284
+ after resolving symlinks in either spelling. A `parent` that is itself a
285
+ link is never an ancestor: unlinking it destroys nothing behind it."""
286
+ if _lexically_inside(child, parent):
287
+ return True
288
+ try:
289
+ if os.path.islink(parent):
290
+ return False
291
+ return _lexically_inside(os.path.realpath(child), os.path.realpath(parent))
292
+ except (OSError, ValueError):
293
+ return False
294
+
295
+
296
+ def removal_would_damage(candidate: str, target: str) -> bool:
297
+ """Whether removing `target` destroys `candidate` OR cuts a piece out of
298
+ it: `target` is a strict subdirectory of the load-bearing `candidate`
299
+ (`sdk remove ~/.alp/sdk-cache/v0.19.0/metadata` against a pinned
300
+ `v0.19.0`). Leaves the install half-present, so it refuses like any other
301
+ orphaning removal."""
302
+ if removal_would_take_out(candidate, target):
303
+ return True
304
+ if not (is_absolute_either_platform(candidate) and is_absolute_either_platform(target)):
305
+ return False
306
+ if _lexically_inside(target, candidate):
307
+ return True
308
+ try:
309
+ if os.path.islink(target):
310
+ return False
311
+ return _lexically_inside(os.path.realpath(target), os.path.realpath(candidate))
312
+ except (OSError, ValueError):
313
+ return False
314
+
315
+
316
+ def compute_tree_bytes(path: Path) -> int:
317
+ """Best-effort total size of `path` -- a single file/symlink's own size, or
318
+ the sum of every regular file under it. `0` for anything that does not
319
+ exist or that this process cannot even list; a size this process could not
320
+ fully account for is reported as the smaller, honest number rather than
321
+ guessed upward.
322
+
323
+ `os.lstat`, not `os.stat`: a symlink is charged for ITS OWN size, never
324
+ the size of whatever it points at, which may sit outside this tree
325
+ entirely (or, if it is a self-referencing loop, infinitely) and must not
326
+ be charged against a removal that will not touch it either --
327
+ `dir_removal.remove_dir` never follows a link out of the tree, and this
328
+ accounting stays consistent with that.
329
+
330
+ Per-entry errors (a permission-denied file, a vanished entry raced by a
331
+ concurrent process) are swallowed via `onerror`/skip rather than aborting
332
+ the whole walk -- this is an advisory `data.freedBytes` figure, not a
333
+ number anything downstream keys a decision on, so a partial count beats no
334
+ count.
335
+ """
336
+ try:
337
+ if path.is_symlink() or path.is_file():
338
+ return os.lstat(path).st_size
339
+ except OSError:
340
+ return 0
341
+ total = 0
342
+ for root, _dirs, files in os.walk(path, onerror=lambda _err: None):
343
+ for name in files:
344
+ try:
345
+ total += os.lstat(os.path.join(root, name)).st_size
346
+ except OSError:
347
+ continue
348
+ return total
349
+
350
+
351
+ #: `OSError.errno` values that mean "a process holds this open right now",
352
+ #: not "permission denied" -- POSIX's own distinction between the two verbs
353
+ #: the tan-cli#790 issue asks to keep apart. `ETXTBSY` is the specific case of
354
+ #: a running interpreter/executable inside the tree (a workspace venv's own
355
+ #: `python`), which is exactly the shape a `tan`-managed SDK cache can hit.
356
+ _IN_USE_ERRNOS = frozenset({errno.EBUSY, errno.ETXTBSY})
357
+
358
+ #: The Windows sibling: `ERROR_SHARING_VIOLATION` (another process opened the
359
+ #: file without share-delete) and `ERROR_LOCK_VIOLATION` (a byte-range lock
360
+ #: held on it). Both are "something has this open", never "the ACL forbids
361
+ #: it" -- `ERROR_ACCESS_DENIED` (5) is the permission arm and is NOT in this
362
+ #: set, so it falls through to the default classification below.
363
+ _IN_USE_WINERRORS = frozenset({32, 33})
364
+
365
+
366
+ def classify_removal_error(err: OSError) -> str:
367
+ """`"in-use"` (something holds a handle) or `"permission"` (an ACL/mode
368
+ bit `dir_removal.remove_dir`'s own read-only retry could not clear) --
369
+ the tan-cli#790 issue's own two verdicts, and the whole reason this
370
+ function exists rather than one generic `sdk.remove.failed`: "distinguish
371
+ the two failure modes ... a wrong verdict sends them hunting a holder
372
+ that does not exist" (the issue's own words, restated in its own
373
+ follow-up comment as the part that survived the Windows-readonly
374
+ correction unchanged).
375
+
376
+ Checked in this order deliberately: `winerror`, when present, is the more
377
+ specific signal a Windows `OSError` carries alongside a translated
378
+ `errno` that may not agree with it (the same precedence
379
+ `bootstrap_cmd._origin_exists` already gives `winerror` over `errno` for
380
+ an unrelated classification, and `dir_removal.os_error_text` gives it for
381
+ display) -- so it is consulted first, and only a `winerror` this function
382
+ does not recognise falls through to the errno table, never the reverse.
383
+ """
384
+ winerror = getattr(err, "winerror", None)
385
+ if winerror is not None:
386
+ if winerror in _IN_USE_WINERRORS:
387
+ return "in-use"
388
+ return "permission"
389
+ if err.errno in _IN_USE_ERRNOS:
390
+ return "in-use"
391
+ return "permission"
392
+
393
+
394
+ def _win_long_path(path: Path) -> str:
395
+ """`path`, prefixed for Windows' `\\\\?\\` extended-length namespace,
396
+ which lifts the legacy 260-character `MAX_PATH` ceiling outright instead
397
+ of merely detecting it after the fact -- the tan-cli#790 issue's own
398
+ closing note calls exactly this shape of tree ("about 3 GB and deep --
399
+ `modules/`, `.venv`, `zephyr`") the one "exactly the shape that trips it
400
+ on a host without long-path support", and a removal is the one operation
401
+ where working around the limit is strictly better than reporting it: a
402
+ `tan sdk install` that got the tree onto disk in the first place already
403
+ proved the HOST can hold these paths, so what usually cannot re-open them
404
+ afterwards is the WIN32 (not NT) API surface a bare `os.remove`/`os.rmdir`
405
+ goes through -- the very thing this prefix routes around, before the fact,
406
+ for every path this removal touches.
407
+
408
+ A no-op on every other platform, and a no-op for a value that is already
409
+ prefixed (a caller-supplied path already spelled in the extended-length
410
+ or UNC-extended form) or is a UNC share (`\\\\server\\share\\...`, which
411
+ gets its OWN prefix form, `\\\\?\\UNC\\server\\share\\...`, not a bare
412
+ `\\\\?\\` glued onto two leading backslashes).
413
+ """
414
+ if os.name != "nt":
415
+ return str(path)
416
+ absolute = os.path.abspath(str(path))
417
+ if absolute.startswith("\\\\?\\"):
418
+ return absolute
419
+ if absolute.startswith("\\\\"):
420
+ return "\\\\?\\UNC\\" + absolute[2:]
421
+ return "\\\\?\\" + absolute
422
+
423
+
424
+ @dataclass(frozen=True)
425
+ class RemovalOutcome:
426
+ """What actually happened when [`remove_sdk_tree`] tried. `ok=False`
427
+ always carries a `kind` (`"in-use"` | `"permission"`) and the OS-rendered
428
+ `detail`; the failing `path`, when the underlying `OSError` named one
429
+ (`err.filename`), else `None` -- a hook-driven `shutil.rmtree` failure
430
+ does not always carry one."""
431
+
432
+ ok: bool
433
+ freed_bytes: int
434
+ kind: str | None = None
435
+ detail: str | None = None
436
+ failing_path: str | None = None
437
+
438
+
439
+ def remove_sdk_tree(path: Path) -> RemovalOutcome:
440
+ """Remove `path` (already confirmed to exist by the caller) with
441
+ `dir_removal.remove_dir`'s read-only-retry/junction-safe hardening, sized
442
+ before and (on a failure) after, so a partial removal still reports how
443
+ much it actually freed rather than zero.
444
+
445
+ Long-path-prefixed on Windows ([`_win_long_path`]) so the size this
446
+ process just proved it could walk is also one this process can delete,
447
+ without the caller ever seeing the raw `MAX_PATH` errno this would
448
+ otherwise surface as an unclassified `"permission"` failure.
449
+ """
450
+ before = compute_tree_bytes(path)
451
+ try:
452
+ remove_dir(_win_long_path(path))
453
+ except (OSError, ValueError) as err:
454
+ after = compute_tree_bytes(path) if path.exists() else 0
455
+ failing_path = getattr(err, "filename", None) if isinstance(err, OSError) else None
456
+ kind = classify_removal_error(err) if isinstance(err, OSError) else "permission"
457
+ detail = os_error_text(err) if isinstance(err, OSError) else str(err)
458
+ return RemovalOutcome(
459
+ ok=False,
460
+ freed_bytes=max(before - after, 0),
461
+ kind=kind,
462
+ detail=detail,
463
+ failing_path=failing_path,
464
+ )
465
+ return RemovalOutcome(ok=True, freed_bytes=before)
@@ -0,0 +1,175 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Input and baud-change actions for `tan monitor --capture` (tan-cli#1451).
3
+
4
+ Pure logic over an already-open port-like object: the `--send` queue (sent
5
+ once, as a burst, at the start / after a delay / on the first line matching a
6
+ regex) and the `--reopen-at BAUD --on REGEX` baud switch (close the port and
7
+ reopen it at the new rate on the first match). The port is opened by the
8
+ caller; reopening goes through the injected `reopen(baud)` callable so this
9
+ module never imports pyserial and works the same over a local tty and a
10
+ pyserial URL such as `rfc2217://`. Clock and sleep are injected for tests.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ import time
17
+ from collections.abc import Callable
18
+ from dataclasses import dataclass, field
19
+ from typing import Any
20
+
21
+ #: Pause between two `--send` items so a firmware that polls one key per tick
22
+ #: sees them separately.
23
+ DEFAULT_SEND_GAP_S = 0.2
24
+ #: Fallback reopen (only when the in-place baud change raises): tries x pause.
25
+ REOPEN_TRIES = 5
26
+ REOPEN_RETRY_S = 0.2
27
+
28
+
29
+ class ActionError(Exception):
30
+ """A send or reopen failed; `kind` is `send` or `reopen`."""
31
+
32
+ def __init__(self, kind: str, message: str) -> None:
33
+ super().__init__(message)
34
+ self.kind = kind
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class ActionSpec:
39
+ sends: tuple[bytes, ...] = ()
40
+ send_after_s: float | None = None
41
+ send_on: re.Pattern[str] | None = None
42
+ send_gap_s: float = DEFAULT_SEND_GAP_S
43
+ reopen_baud: int | None = None
44
+ reopen_on: re.Pattern[str] | None = None
45
+
46
+ @property
47
+ def watches_lines(self) -> bool:
48
+ return self.send_on is not None or self.reopen_on is not None
49
+
50
+
51
+ def _default_write(port: Any, data: bytes) -> None:
52
+ port.write(data)
53
+
54
+
55
+ @dataclass
56
+ class CaptureActions:
57
+ """The mutable run state. `port` is the CURRENT port: the capture loop reads
58
+ it each iteration, because a reopen replaces it."""
59
+
60
+ spec: ActionSpec
61
+ port: Any
62
+ reopen: Callable[[int], Any]
63
+ write: Callable[[Any, bytes], None] = _default_write
64
+ clock: Callable[[], float] = time.monotonic
65
+ sleep: Callable[[float], None] = time.sleep
66
+ events: list[dict[str, Any]] = field(default_factory=list)
67
+ _start: float = field(init=False)
68
+ _sent: bool = False
69
+ _reopened: bool = False
70
+ _flushed: bool = False
71
+
72
+ def __post_init__(self) -> None:
73
+ self._start = self.clock()
74
+
75
+ @property
76
+ def sends_pending(self) -> int:
77
+ return 0 if self._sent else len(self.spec.sends)
78
+
79
+ def _at(self) -> float:
80
+ return round(self.clock() - self._start, 3)
81
+
82
+ def tick(self) -> None:
83
+ """Fire the unconditional / timed send when it is due."""
84
+ spec = self.spec
85
+ if self._sent or not spec.sends or spec.send_on is not None:
86
+ return
87
+ if spec.send_after_s is None or self.clock() - self._start >= spec.send_after_s:
88
+ self._send()
89
+
90
+ def take_flush(self) -> bool:
91
+ """True once after a baud change: the caller must drop its partial-line
92
+ buffer and the rest of the current read, which are old-baud garbage."""
93
+ flushed, self._flushed = self._flushed, False
94
+ return flushed
95
+
96
+ def line(self, text: str, partial: bool = False) -> None:
97
+ """Offer a complete or partial line; each regex fires at most once.
98
+ `--send-on` also matches an unterminated partial line (a prompt);
99
+ `--on` (the baud switch) matches COMPLETE lines only, so a half-received
100
+ line is never taken for the trigger."""
101
+ spec = self.spec
102
+ if spec.sends and not self._sent and spec.send_on is not None and spec.send_on.search(text):
103
+ self._send()
104
+ if (
105
+ spec.reopen_baud is not None
106
+ and not self._reopened
107
+ and not partial
108
+ and spec.reopen_on is not None
109
+ and spec.reopen_on.search(text)
110
+ ):
111
+ self._reopen(spec.reopen_baud, text)
112
+
113
+ def _send(self) -> None:
114
+ self._sent = True
115
+ total = 0
116
+ try:
117
+ for i, item in enumerate(self.spec.sends):
118
+ if i:
119
+ self.sleep(self.spec.send_gap_s)
120
+ self.write(self.port, item)
121
+ total += len(item)
122
+ flush = getattr(self.port, "flush", None)
123
+ if callable(flush):
124
+ flush()
125
+ except OSError as err:
126
+ self.events.append(
127
+ {"action": "send", "atSeconds": self._at(), "failed": True, "bytes": total,
128
+ "error": str(err)}
129
+ )
130
+ raise ActionError("send", f"writing --send input failed: {err}") from err
131
+ self.events.append(
132
+ {"action": "send", "atSeconds": self._at(), "items": len(self.spec.sends), "bytes": total}
133
+ )
134
+
135
+ def _reopen(self, baud: int, matched: str) -> None:
136
+ """Change the baud on the first match. Preferred: in place
137
+ (`port.baudrate = N`, supported by local serial and pyserial's
138
+ rfc2217://), so the link never closes and no byte is lost to a reopen
139
+ gap. Only if that raises: close, then reopen with a bounded retry
140
+ (`REOPEN_TRIES` x `REOPEN_RETRY_S`). Residual gap on the fallback path:
141
+ bytes sent while the port is closed are lost, and a ser2net session
142
+ may take a moment to accept the new connection."""
143
+ self._reopened = True
144
+ method = "in-place"
145
+ try:
146
+ self.port.baudrate = baud
147
+ drop = getattr(self.port, "reset_input_buffer", None)
148
+ if callable(drop):
149
+ drop() # old-baud garbage still queued in the driver
150
+ except Exception: # noqa: BLE001 -- any refusal falls back to a reopen
151
+ method = "reopen"
152
+ self._close_and_open(baud)
153
+ self._flushed = True
154
+ self.events.append(
155
+ {"action": "reopen", "atSeconds": self._at(), "baud": baud, "method": method,
156
+ "matchedLine": matched[-256:]}
157
+ )
158
+
159
+ def _close_and_open(self, baud: int) -> None:
160
+ try:
161
+ self.port.close()
162
+ except Exception: # noqa: BLE001 -- closing a dead link is not the failure being reported
163
+ pass
164
+ last: Exception | None = None
165
+ for attempt in range(REOPEN_TRIES):
166
+ if attempt:
167
+ self.sleep(REOPEN_RETRY_S)
168
+ try:
169
+ self.port = self.reopen(baud)
170
+ return
171
+ except Exception as err: # noqa: BLE001 -- any open failure is retried
172
+ last = err
173
+ raise ActionError(
174
+ "reopen", f"reopening the port at {baud} baud failed after {REOPEN_TRIES} tries: {last}"
175
+ ) from last