tan-cli 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (330) hide show
  1. tan/__init__.py +0 -0
  2. tan/__main__.py +120 -0
  3. tan/cli.py +939 -0
  4. tan/command_table.py +265 -0
  5. tan/commands/__init__.py +8 -0
  6. tan/commands/bootstrap_cmd.py +4856 -0
  7. tan/commands/bootstrap_patches.py +164 -0
  8. tan/commands/bootstrap_toolchain_fetch.py +155 -0
  9. tan/commands/bootstrap_toolchain_pin.py +36 -0
  10. tan/commands/build/__init__.py +1 -0
  11. tan/commands/build/configure_inputs.py +277 -0
  12. tan/commands/build/execute.py +1852 -0
  13. tan/commands/build/host_python.py +59 -0
  14. tan/commands/build/link_stale.py +78 -0
  15. tan/commands/build/manifest.py +428 -0
  16. tan/commands/build/materialise.py +81 -0
  17. tan/commands/build/token_substitution.py +381 -0
  18. tan/commands/build/toolchain.py +482 -0
  19. tan/commands/build/user_defines_wipe.py +115 -0
  20. tan/commands/build/workspace_patches.py +65 -0
  21. tan/commands/build_cmd.py +2236 -0
  22. tan/commands/build_output.py +331 -0
  23. tan/commands/clean_cmd.py +1183 -0
  24. tan/commands/completion_cmd.py +786 -0
  25. tan/commands/debug_config_cmd.py +2425 -0
  26. tan/commands/diff_cmd.py +940 -0
  27. tan/commands/doctor_cmd.py +4767 -0
  28. tan/commands/examples_cmd.py +644 -0
  29. tan/commands/explain_cmd.py +1319 -0
  30. tan/commands/faultdecode_cmd.py +931 -0
  31. tan/commands/flash_atoc_guard.py +240 -0
  32. tan/commands/flash_cmd.py +5731 -0
  33. tan/commands/flash_mram_guard.py +150 -0
  34. tan/commands/flash_ram.py +578 -0
  35. tan/commands/flash_raw.py +583 -0
  36. tan/commands/generate_cmd.py +1456 -0
  37. tan/commands/image_cmd.py +753 -0
  38. tan/commands/init_cmd.py +1740 -0
  39. tan/commands/inspect_cmd.py +377 -0
  40. tan/commands/kconfig_cmd.py +734 -0
  41. tan/commands/model_cmd.py +1514 -0
  42. tan/commands/model_device_cmd.py +351 -0
  43. tan/commands/model_device_live.py +234 -0
  44. tan/commands/model_host_cmd.py +430 -0
  45. tan/commands/model_zoo_cmd.py +363 -0
  46. tan/commands/monitor_actions.py +125 -0
  47. tan/commands/monitor_cmd.py +626 -0
  48. tan/commands/monitor_session.py +505 -0
  49. tan/commands/new_som_cmd.py +1522 -0
  50. tan/commands/pinmux_cmd.py +568 -0
  51. tan/commands/presets_cmd.py +1123 -0
  52. tan/commands/probe_cmd.py +421 -0
  53. tan/commands/reset_cmd.py +354 -0
  54. tan/commands/reset_confirm.py +168 -0
  55. tan/commands/run_cmd.py +637 -0
  56. tan/commands/scaffold_cmd.py +490 -0
  57. tan/commands/sdk_cmd.py +1723 -0
  58. tan/commands/size_cmd.py +1020 -0
  59. tan/commands/support_bundle_cmd.py +1073 -0
  60. tan/commands/trace_cmd.py +341 -0
  61. tan/commands/validate_cmd.py +2009 -0
  62. tan/commands/west_forward_cmd.py +736 -0
  63. tan/commands/workspace_patch_check.py +296 -0
  64. tan/core/__init__.py +1 -0
  65. tan/core/alp_conf_pregen.py +100 -0
  66. tan/core/artifact_provenance.py +240 -0
  67. tan/core/atoc_guard.py +276 -0
  68. tan/core/atoc_guard_messages.py +232 -0
  69. tan/core/atoc_replacement.py +118 -0
  70. tan/core/atomic_write.py +178 -0
  71. tan/core/board_context.py +351 -0
  72. tan/core/board_diagnostic.py +111 -0
  73. tan/core/board_files.py +221 -0
  74. tan/core/board_validator.py +542 -0
  75. tan/core/board_validator_compat.py +196 -0
  76. tan/core/board_validator_run.py +129 -0
  77. tan/core/board_validator_schema.py +186 -0
  78. tan/core/board_validator_skew.py +46 -0
  79. tan/core/board_yaml_edit.py +295 -0
  80. tan/core/board_yaml_pos.py +118 -0
  81. tan/core/bootstrap.py +2572 -0
  82. tan/core/build_plan.py +625 -0
  83. tan/core/consent.py +104 -0
  84. tan/core/console_filter.py +245 -0
  85. tan/core/debug_launch.py +1803 -0
  86. tan/core/devicetree_lint.py +168 -0
  87. tan/core/dir_removal.py +229 -0
  88. tan/core/doctor_git.py +172 -0
  89. tan/core/doctor_libraries.py +253 -0
  90. tan/core/doctor_render.py +185 -0
  91. tan/core/doctor_scope.py +66 -0
  92. tan/core/doctor_setools.py +237 -0
  93. tan/core/doctor_stale.py +180 -0
  94. tan/core/document_guards.py +723 -0
  95. tan/core/dp_id.py +97 -0
  96. tan/core/elf_load.py +63 -0
  97. tan/core/error_catalog.py +171 -0
  98. tan/core/example_catalog.py +499 -0
  99. tan/core/example_facets.py +151 -0
  100. tan/core/faultdecode.py +521 -0
  101. tan/core/flash_plan.py +2501 -0
  102. tan/core/flow_d_report.py +381 -0
  103. tan/core/flow_d_uptime.py +164 -0
  104. tan/core/fs_confine.py +130 -0
  105. tan/core/global_flags.py +230 -0
  106. tan/core/host_python.py +210 -0
  107. tan/core/image_bundle.py +247 -0
  108. tan/core/inert.py +147 -0
  109. tan/core/jlink_binary.py +128 -0
  110. tan/core/jlink_probe.py +481 -0
  111. tan/core/jsonc_splice.py +404 -0
  112. tan/core/launch_provenance.py +257 -0
  113. tan/core/link_refusal.py +76 -0
  114. tan/core/metadata_schema.py +376 -0
  115. tan/core/model_check.py +227 -0
  116. tan/core/model_device.py +449 -0
  117. tan/core/model_doctor.py +198 -0
  118. tan/core/model_host.py +48 -0
  119. tan/core/model_list.py +112 -0
  120. tan/core/model_zoo.py +353 -0
  121. tan/core/module_template.py +340 -0
  122. tan/core/mram_link.py +87 -0
  123. tan/core/new_som_ospi.py +94 -0
  124. tan/core/os_class.py +159 -0
  125. tan/core/patch_paths.py +87 -0
  126. tan/core/pending.py +40 -0
  127. tan/core/plain_zephyr_plan.py +138 -0
  128. tan/core/plan_exec.py +373 -0
  129. tan/core/plan_tokens.py +600 -0
  130. tan/core/probe.py +155 -0
  131. tan/core/probe_plan.py +237 -0
  132. tan/core/proxy.py +168 -0
  133. tan/core/publish.py +31 -0
  134. tan/core/python_floor.py +178 -0
  135. tan/core/ram_run.py +694 -0
  136. tan/core/ram_watch.py +231 -0
  137. tan/core/raw_write.py +160 -0
  138. tan/core/reset_plan.py +56 -0
  139. tan/core/run.py +161 -0
  140. tan/core/scaffold.py +2203 -0
  141. tan/core/scaffold_selftest_identity.py +225 -0
  142. tan/core/sdk_default_registry.py +565 -0
  143. tan/core/sdk_discovery.py +996 -0
  144. tan/core/sdk_removal.py +465 -0
  145. tan/core/serial_actions.py +175 -0
  146. tan/core/serial_capture.py +185 -0
  147. tan/core/serial_url.py +45 -0
  148. tan/core/setools.py +608 -0
  149. tan/core/setools_scratch.py +243 -0
  150. tan/core/shapes.py +194 -0
  151. tan/core/size.py +610 -0
  152. tan/core/som_buildability.py +204 -0
  153. tan/core/som_schema_version.py +62 -0
  154. tan/core/subprocess_env.py +201 -0
  155. tan/core/sysbuild_image.py +94 -0
  156. tan/core/system_manifest.py +827 -0
  157. tan/core/text_layout.py +73 -0
  158. tan/core/timestamp.py +182 -0
  159. tan/core/tool_lookup.py +383 -0
  160. tan/core/toolchain_pin.py +117 -0
  161. tan/core/toolchain_provision.py +1224 -0
  162. tan/core/trusted_path.py +134 -0
  163. tan/core/uboot_breakin.py +154 -0
  164. tan/core/uri_reference.py +415 -0
  165. tan/core/user_defines.py +152 -0
  166. tan/core/venv.py +332 -0
  167. tan/core/west_patches.py +143 -0
  168. tan/core/west_workspace_refusal.py +159 -0
  169. tan/core/zephyr_env.py +124 -0
  170. tan/env.py +185 -0
  171. tan/envelope.py +647 -0
  172. tan/exit_codes.py +12 -0
  173. tan/lazy_typer.py +185 -0
  174. tan/model/__init__.py +2 -0
  175. tan/model/_gen_fixture.py +132 -0
  176. tan/model/adapters/__init__.py +116 -0
  177. tan/model/adapters/cpu.py +26 -0
  178. tan/model/adapters/deepx.py +94 -0
  179. tan/model/adapters/drpai.py +242 -0
  180. tan/model/adapters/ethos_u.py +951 -0
  181. tan/model/adapters/executorch.py +55 -0
  182. tan/model/analyze.py +694 -0
  183. tan/model/build.py +304 -0
  184. tan/model/check.py +682 -0
  185. tan/model/manifest.py +625 -0
  186. tan/model/measure.py +216 -0
  187. tan/model/npu_ops_validate.py +130 -0
  188. tan/model/package.py +125 -0
  189. tan/model/perf.py +635 -0
  190. tan/model/perf_apply.py +721 -0
  191. tan/model/prep.py +193 -0
  192. tan/model/sram_fit.py +502 -0
  193. tan/model/targets.py +374 -0
  194. tan/model/tensorio.py +287 -0
  195. tan/net.py +73 -0
  196. tan/output_format.py +213 -0
  197. tan/planner/__init__.py +219 -0
  198. tan/planner/aperture.py +202 -0
  199. tan/planner/buildplan.py +1092 -0
  200. tan/planner/camera_owner.py +192 -0
  201. tan/planner/cameras.py +132 -0
  202. tan/planner/carveout.py +755 -0
  203. tan/planner/cli.py +274 -0
  204. tan/planner/headers.py +315 -0
  205. tan/planner/kconfig.py +2435 -0
  206. tan/planner/kconfig_symbols.py +473 -0
  207. tan/planner/libraries.py +677 -0
  208. tan/planner/link_target.py +237 -0
  209. tan/planner/linux_ownership.py +216 -0
  210. tan/planner/loader.py +1525 -0
  211. tan/planner/manifest.py +116 -0
  212. tan/planner/memory.py +161 -0
  213. tan/planner/memregion.py +56 -0
  214. tan/planner/models.py +533 -0
  215. tan/planner/orchestrator.py +874 -0
  216. tan/planner/ownership.py +203 -0
  217. tan/planner/partition.py +1160 -0
  218. tan/planner/paths.py +32 -0
  219. tan/planner/plain_slice.py +139 -0
  220. tan/planner/project_emit/__init__.py +60 -0
  221. tan/planner/project_emit/bom_netlist.py +375 -0
  222. tan/planner/project_emit/dts.py +580 -0
  223. tan/planner/project_emit/hw_info.py +221 -0
  224. tan/planner/project_emit/native_sim.py +136 -0
  225. tan/planner/project_emit/west_libs.py +213 -0
  226. tan/planner/project_loader.py +264 -0
  227. tan/planner/sdk_capability.py +203 -0
  228. tan/planner/sdk_compat.py +395 -0
  229. tan/planner/secure.py +350 -0
  230. tan/planner/slugs.py +394 -0
  231. tan/planner/som_metadata.py +325 -0
  232. tan/planner/strict_loaders.py +131 -0
  233. tan/planner/template.py +1037 -0
  234. tan/planner/template_pins.py +535 -0
  235. tan/planner/template_rewrite.py +714 -0
  236. tan/planner/topology.py +208 -0
  237. tan/planner/validate.py +365 -0
  238. tan/planner/whole_device_alias.py +67 -0
  239. tan/planner/zephyr_board.py +3510 -0
  240. tan/planner_cli.py +97 -0
  241. tan/planner_emit.py +617 -0
  242. tan/planner_root.py +119 -0
  243. tan/soc_ref.py +58 -0
  244. tan/templates/__init__.py +34 -0
  245. tan/templates/vendored/MANIFEST.md +1862 -0
  246. tan/templates/vendored/diagnostics/E1M-AEN801/CMakeLists.txt +8 -0
  247. tan/templates/vendored/diagnostics/E1M-AEN801/README.md +112 -0
  248. tan/templates/vendored/diagnostics/E1M-AEN801/board.yaml +38 -0
  249. tan/templates/vendored/diagnostics/E1M-AEN801/boards/native_sim_native_64.conf +11 -0
  250. tan/templates/vendored/diagnostics/E1M-AEN801/boards/native_sim_native_64.overlay +29 -0
  251. tan/templates/vendored/diagnostics/E1M-AEN801/prj.conf +5 -0
  252. tan/templates/vendored/diagnostics/E1M-AEN801/src/main.c +266 -0
  253. tan/templates/vendored/diagnostics/E1M-AEN801/testcase.yaml +53 -0
  254. tan/templates/vendored/diagnostics/E1M-V2N101/CMakeLists.txt +8 -0
  255. tan/templates/vendored/diagnostics/E1M-V2N101/README.md +112 -0
  256. tan/templates/vendored/diagnostics/E1M-V2N101/board.yaml +38 -0
  257. tan/templates/vendored/diagnostics/E1M-V2N101/boards/native_sim_native_64.conf +11 -0
  258. tan/templates/vendored/diagnostics/E1M-V2N101/boards/native_sim_native_64.overlay +29 -0
  259. tan/templates/vendored/diagnostics/E1M-V2N101/prj.conf +5 -0
  260. tan/templates/vendored/diagnostics/E1M-V2N101/src/main.c +266 -0
  261. tan/templates/vendored/diagnostics/E1M-V2N101/testcase.yaml +53 -0
  262. tan/templates/vendored/edge-ai/E1M-AEN801/CMakeLists.txt +10 -0
  263. tan/templates/vendored/edge-ai/E1M-AEN801/README.md +75 -0
  264. tan/templates/vendored/edge-ai/E1M-AEN801/board.yaml +38 -0
  265. tan/templates/vendored/edge-ai/E1M-AEN801/prj.conf +8 -0
  266. tan/templates/vendored/edge-ai/E1M-AEN801/src/cold_chain.c +371 -0
  267. tan/templates/vendored/edge-ai/E1M-AEN801/src/cold_chain.h +178 -0
  268. tan/templates/vendored/edge-ai/E1M-AEN801/src/main.c +373 -0
  269. tan/templates/vendored/edge-ai/E1M-AEN801/testcase.yaml +62 -0
  270. tan/templates/vendored/edge-ai/E1M-V2N101/CMakeLists.txt +10 -0
  271. tan/templates/vendored/edge-ai/E1M-V2N101/README.md +75 -0
  272. tan/templates/vendored/edge-ai/E1M-V2N101/board.yaml +37 -0
  273. tan/templates/vendored/edge-ai/E1M-V2N101/prj.conf +8 -0
  274. tan/templates/vendored/edge-ai/E1M-V2N101/src/cold_chain.c +371 -0
  275. tan/templates/vendored/edge-ai/E1M-V2N101/src/cold_chain.h +178 -0
  276. tan/templates/vendored/edge-ai/E1M-V2N101/src/main.c +373 -0
  277. tan/templates/vendored/edge-ai/E1M-V2N101/testcase.yaml +62 -0
  278. tan/templates/vendored/iot/E1M-AEN801/CMakeLists.txt +14 -0
  279. tan/templates/vendored/iot/E1M-AEN801/README.md +103 -0
  280. tan/templates/vendored/iot/E1M-AEN801/board.yaml +69 -0
  281. tan/templates/vendored/iot/E1M-AEN801/prj.conf +15 -0
  282. tan/templates/vendored/iot/E1M-AEN801/src/cc3501e_bridge.c +196 -0
  283. tan/templates/vendored/iot/E1M-AEN801/src/cc3501e_bridge.h +173 -0
  284. tan/templates/vendored/iot/E1M-AEN801/src/main.c +262 -0
  285. tan/templates/vendored/iot/E1M-AEN801/testcase.yaml +68 -0
  286. tan/templates/vendored/minimal/E1M-AEN801/CMakeLists.txt +8 -0
  287. tan/templates/vendored/minimal/E1M-AEN801/README.md +108 -0
  288. tan/templates/vendored/minimal/E1M-AEN801/board.yaml +32 -0
  289. tan/templates/vendored/minimal/E1M-AEN801/prj.conf +8 -0
  290. tan/templates/vendored/minimal/E1M-AEN801/src/main.c +111 -0
  291. tan/templates/vendored/minimal/E1M-AEN801/testcase.yaml +49 -0
  292. tan/templates/vendored/minimal/E1M-V2N101/CMakeLists.txt +8 -0
  293. tan/templates/vendored/minimal/E1M-V2N101/README.md +108 -0
  294. tan/templates/vendored/minimal/E1M-V2N101/board.yaml +32 -0
  295. tan/templates/vendored/minimal/E1M-V2N101/prj.conf +8 -0
  296. tan/templates/vendored/minimal/E1M-V2N101/src/main.c +111 -0
  297. tan/templates/vendored/minimal/E1M-V2N101/testcase.yaml +49 -0
  298. tan/templates/vendored/multicore-mailbox/E1M-AEN801/CMakeLists.txt +8 -0
  299. tan/templates/vendored/multicore-mailbox/E1M-AEN801/README.md +120 -0
  300. tan/templates/vendored/multicore-mailbox/E1M-AEN801/board.yaml +57 -0
  301. tan/templates/vendored/multicore-mailbox/E1M-AEN801/boards/native_sim_native_64.overlay +40 -0
  302. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/CMakeLists.txt +13 -0
  303. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/main.c +171 -0
  304. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/prj.conf +17 -0
  305. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/testcase.yaml +23 -0
  306. tan/templates/vendored/multicore-mailbox/E1M-AEN801/prj.conf +22 -0
  307. tan/templates/vendored/multicore-mailbox/E1M-AEN801/src/main.c +185 -0
  308. tan/templates/vendored/multicore-mailbox/E1M-AEN801/testcase.yaml +26 -0
  309. tan/templates/vendored/sensor/E1M-AEN801/CMakeLists.txt +8 -0
  310. tan/templates/vendored/sensor/E1M-AEN801/README.md +112 -0
  311. tan/templates/vendored/sensor/E1M-AEN801/board.yaml +57 -0
  312. tan/templates/vendored/sensor/E1M-AEN801/boards/native_sim_native_64.conf +10 -0
  313. tan/templates/vendored/sensor/E1M-AEN801/boards/native_sim_native_64.overlay +28 -0
  314. tan/templates/vendored/sensor/E1M-AEN801/prj.conf +5 -0
  315. tan/templates/vendored/sensor/E1M-AEN801/src/main.c +186 -0
  316. tan/templates/vendored/sensor/E1M-AEN801/testcase.yaml +52 -0
  317. tan/templates/vendored/sensor/E1M-V2N101/CMakeLists.txt +8 -0
  318. tan/templates/vendored/sensor/E1M-V2N101/README.md +112 -0
  319. tan/templates/vendored/sensor/E1M-V2N101/board.yaml +57 -0
  320. tan/templates/vendored/sensor/E1M-V2N101/boards/native_sim_native_64.conf +10 -0
  321. tan/templates/vendored/sensor/E1M-V2N101/boards/native_sim_native_64.overlay +28 -0
  322. tan/templates/vendored/sensor/E1M-V2N101/prj.conf +5 -0
  323. tan/templates/vendored/sensor/E1M-V2N101/src/main.c +186 -0
  324. tan/templates/vendored/sensor/E1M-V2N101/testcase.yaml +52 -0
  325. tan/version.py +102 -0
  326. tan_cli-0.7.0.dist-info/METADATA +125 -0
  327. tan_cli-0.7.0.dist-info/RECORD +330 -0
  328. tan_cli-0.7.0.dist-info/WHEEL +5 -0
  329. tan_cli-0.7.0.dist-info/entry_points.txt +2 -0
  330. tan_cli-0.7.0.dist-info/top_level.txt +1 -0
tan/core/probe.py ADDED
@@ -0,0 +1,155 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """The two subprocess primitives every host/tool probe in `tan doctor` (and
3
+ its git-provenance/build-side counterparts, `tan.core.doctor_git`) is built
4
+ on. Extracted out of `tan.commands.doctor_cmd` (tan-cli#797 module-size
5
+ budget) so `tan.core.doctor_git` can depend on them without an import cycle
6
+ back into `doctor_cmd` -- `tan/core` is the layer BELOW `tan/commands`, never
7
+ the reverse.
8
+
9
+ Neither function can raise: every subprocess failure mode a fresh, possibly
10
+ broken host can produce (missing binary, permission error, a probe that hangs
11
+ forever, garbage output) is swallowed and turned into "no answer", never a
12
+ traceback that would misrepresent a fixable environment problem as a tan bug.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import contextlib
17
+ import os
18
+ import re
19
+ import subprocess
20
+ import tempfile
21
+
22
+ from tan.core.subprocess_env import spawn_env
23
+
24
+ #: Every probe in this module gets the same ceiling: long enough for a slow
25
+ #: but genuinely-answering tool, short enough that a hung one does not stall
26
+ #: /a build's provenance read indefinitely.
27
+ PROBE_TIMEOUT_S = 15
28
+
29
+
30
+ _PYTHON_NAME = re.compile(r"^(py|python[\d.]*[tw]?)(\.exe)?$", re.IGNORECASE)
31
+ #: `-c`, or a bundle ending in it (`-Ic`, `-Sc`, `-Bsc`).
32
+ _DASH_C = re.compile(r"^-[A-Za-z]*c$")
33
+
34
+
35
+ def isolated_cwd() -> "tempfile.TemporaryDirectory[str]":
36
+ """A fresh EMPTY directory to use as a child's cwd. `python -m <mod>` and
37
+ `python -c` put the cwd on `sys.path`, so a project directory can shadow
38
+ `pip`/`venv`/a stdlib module; every spawn that must not be hijackable runs
39
+ from here, with absolute path arguments (tan-cli#1317, #1331). The one
40
+ helper both the probes and `bootstrap_cmd.Runner.run(isolated=True)` use."""
41
+ return tempfile.TemporaryDirectory(prefix="tan-spawn-", ignore_cleanup_errors=True)
42
+
43
+
44
+ def is_python_dash_c(argv: list[str]) -> bool:
45
+ """`<python> ... -c <code>`. With `-c`, `sys.path[0]` is the CWD, so a
46
+ `west.py`/`json.py` planted in the user's project or SDK dir would run
47
+ during a probe. Such probes run from a fresh EMPTY directory instead."""
48
+ return (
49
+ bool(argv)
50
+ and any(_DASH_C.match(a) for a in argv[1:])
51
+ and bool(_PYTHON_NAME.match(os.path.basename(argv[0])))
52
+ )
53
+
54
+
55
+ def _absolute_for_new_cwd(argv: list[str], executable: str | None):
56
+ """The probe runs from a different cwd, so a RELATIVE program path (one with
57
+ a separator) must be made absolute first or it would stop resolving."""
58
+ def fix(p: str) -> str:
59
+ return os.path.abspath(p) if (os.sep in p or (os.altsep and os.altsep in p)) else p
60
+
61
+ return [fix(argv[0]), *argv[1:]], (fix(executable) if executable else executable)
62
+
63
+
64
+ def probe_status(
65
+ argv: list[str], timeout: int = PROBE_TIMEOUT_S, executable: str | None = None
66
+ ) -> tuple[bool, str | None]:
67
+ """Run `argv` and return `(ran, stdout)`.
68
+
69
+ `executable`, when given, is `lpApplicationName`/POSIX `execve`'s own
70
+ program path -- the PATH-RESOLVED absolute location `tool_lookup.resolve_tool`
71
+ found, threaded through by a caller that needs `argv[0]` to stay the bare
72
+ identity (so a tool's own diagnostics keep printing the short name) while
73
+ the OS loads the hardened path instead of re-searching for the bare one
74
+ itself (tan-cli#797, the same `executable=` shape tan-cli#567 established
75
+ for the flash/size spawns).
76
+
77
+ `ran` is `True` exactly when the process was actually spawned AND exited
78
+ zero -- the one predicate that answers "could a build slice run this
79
+ binary at all". `stdout` is the captured text only when `ran` is `True`,
80
+ else always `None`. `probe()` below collapses both into a single `None`,
81
+ which is right for most callers (an unparseable version banner and a
82
+ binary that could not be spawned are both just "no answer") but wrong for
83
+ a caller that needs to tell "spawned fine, printed garbage" apart from
84
+ "never ran at all" -- `doctor_cmd.west_resolved_check` is exactly that
85
+ caller (tan-cli#488 defect 1): a `west` launcher whose file survives but
86
+ whose interpreter does not (a relocated workspace, a deleted
87
+ `.venv/bin/python`) is `is_file()`-true and therefore reaches this probe,
88
+ but every real invocation of it fails with `OSError`/a non-zero exit --
89
+ `probe()` alone cannot distinguish that from a `west` that ran and printed
90
+ something this command's regex could not parse, and reporting the former
91
+ as `pass` is the false green this function exists to close.
92
+
93
+ See `probe()` for why each failure mode below is swallowed rather than
94
+ raised.
95
+
96
+ `env=spawn_env()` (tan-cli#992): every probe in this module IS the
97
+ "does tan think this tool works" verdict, so a probe that leaked tan's
98
+ own bundled `LD_LIBRARY_PATH` into the child would report a perfectly
99
+ fine host tool as broken the moment its own bundled copy of some shared
100
+ library disagreed with the host's -- exactly the failure mode
101
+ `spawn_env` exists to close.
102
+ """
103
+ isolate = is_python_dash_c(argv)
104
+ if isolate:
105
+ argv, executable = _absolute_for_new_cwd(argv, executable)
106
+ scratch = (
107
+ isolated_cwd()
108
+ if isolate
109
+ else contextlib.nullcontext(None)
110
+ )
111
+ try:
112
+ with scratch as cwd:
113
+ out = subprocess.run(
114
+ argv,
115
+ executable=executable,
116
+ cwd=cwd,
117
+ capture_output=True,
118
+ text=True,
119
+ encoding="utf-8",
120
+ errors="replace",
121
+ stdin=subprocess.DEVNULL,
122
+ timeout=timeout,
123
+ env=spawn_env(),
124
+ check=False,
125
+ )
126
+ except (OSError, ValueError, subprocess.SubprocessError):
127
+ # SubprocessError covers TimeoutExpired (the child is already killed by
128
+ # `run`); ValueError catches an empty/garbage argv rather than letting
129
+ # it escape as a traceback.
130
+ return False, None
131
+ return (out.returncode == 0), (out.stdout if out.returncode == 0 else None)
132
+
133
+
134
+ def probe(
135
+ argv: list[str], timeout: int = PROBE_TIMEOUT_S, executable: str | None = None
136
+ ) -> str | None:
137
+ """Run `argv` and return its stdout, or `None` for every way that can fail.
138
+
139
+ `None` means "no answer", never "the answer is bad" -- callers must not read
140
+ it as a verdict. The failure modes this swallows are all real on a fresh
141
+ host: the binary is absent (`FileNotFoundError`), it is a directory or not
142
+ executable (`OSError`/`PermissionError`), it waits forever on a probe that is
143
+ not plugged in (`TimeoutExpired`), or it exits non-zero.
144
+
145
+ `stdin` is closed, not inherited: a tool that decides to prompt then reads
146
+ EOF and dies instead of blocking until the timeout. `errors="replace"` is
147
+ the same reason `tests/conformance` uses it -- a tool answering in the
148
+ platform code page must not turn into a `UnicodeDecodeError` crash that
149
+ masquerades as a host problem.
150
+
151
+ A thin wrapper over `probe_status` (above) for every caller that only ever
152
+ wanted "the answer, or nothing" and has no use for the ran/did-not-run
153
+ split.
154
+ """
155
+ return probe_status(argv, timeout, executable=executable)[1]
tan/core/probe_plan.py ADDED
@@ -0,0 +1,237 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """tan-cli#1406: the pure half of `tan probe` -- argument parsing, the region
3
+ safety rule and the read-only J-Link Commander scripts.
4
+
5
+ `tan probe` exists so the bench never needs raw `JLinkExe` for two READ-ONLY
6
+ questions: "which SW-DP / core is attached?" and "what do these words read?".
7
+ Every script built here is `connect` plus `mem32` reads and `exit`; nothing in
8
+ this module can emit a write, erase, `loadbin`, `setpc`, `go`, reset or
9
+ `w1`/`w2`/`w4` command, and `FORBIDDEN_VERBS` is what the tests hold the
10
+ generated scripts to.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from typing import Sequence
16
+
17
+ from tan.core.ram_run import core_check_script, preamble
18
+
19
+ CODE_BAD_ARGUMENT = "probe.bad-argument"
20
+ CODE_READ_TOO_LARGE = "probe.read-too-large"
21
+ CODE_READ_UNSAFE_REGION = "probe.read-unsafe-region"
22
+ CODE_READ_FAILED = "probe.read-failed"
23
+ CODE_FAILED = "probe.failed"
24
+
25
+ #: Most words one `probe read` returns.
26
+ MAX_WORDS = 256
27
+ DEFAULT_WORDS = 4
28
+ #: The generic Cortex-M55 attach profile (the --ram attach check's own).
29
+ DEFAULT_DEVICE = "Cortex-M55"
30
+ CORES = ("m55_he", "m55_hp")
31
+
32
+ #: The window a session attached to the M55-HE must never touch: reading the HP
33
+ #: ITCM global alias from an HE attach leaves the core unhaltable until a PIN reset
34
+ #: (bench round 8, tan-cli#1354).
35
+ UNSAFE_WINDOW = (0x50000000, 0x5FFFFFFF)
36
+
37
+ #: Commander verbs a `probe` script must never contain (the tests scan for them).
38
+ FORBIDDEN_VERBS = (
39
+ "w1", "w2", "w4", "w8", "write", "erase", "loadbin", "loadfile", "setpc", "go", "g",
40
+ "reset", "r", "rx", "halt", "h", "unlock", "wreg", "savebin", "setbp", "step", "s",
41
+ )
42
+
43
+ _HEX = re.compile(r"^0[xX][0-9A-Fa-f]{1,8}$")
44
+ _DEC = re.compile(r"^[0-9]{1,10}$")
45
+
46
+
47
+ class ProbeArgError(ValueError):
48
+ def __init__(self, code: str, message: str) -> None:
49
+ super().__init__(message)
50
+ self.code = code
51
+
52
+
53
+ def parse_number(text: str, what: str) -> int:
54
+ """Plain hex (`0x...`) or decimal only -- no signs, underscores, expressions."""
55
+ text = text.strip() if isinstance(text, str) else ""
56
+ if _HEX.match(text):
57
+ return int(text, 16)
58
+ if _DEC.match(text):
59
+ return int(text, 10)
60
+ raise ProbeArgError(
61
+ CODE_BAD_ARGUMENT, f"{what} {text!r} is not a plain hex (0x...) or decimal number"
62
+ )
63
+
64
+
65
+ def parse_read(address: str, count: str | int | None) -> tuple[int, int]:
66
+ """`(address, words)` for a `probe read`, or `ProbeArgError`."""
67
+ addr = parse_number(address, "the address")
68
+ if addr % 4:
69
+ raise ProbeArgError(CODE_BAD_ARGUMENT, f"address 0x{addr:X} is not 4-byte aligned")
70
+ if count is None:
71
+ words = DEFAULT_WORDS
72
+ else:
73
+ words = parse_number(str(count), "the word count")
74
+ if words < 1:
75
+ raise ProbeArgError(CODE_BAD_ARGUMENT, "the word count must be at least 1")
76
+ if words > MAX_WORDS:
77
+ raise ProbeArgError(
78
+ CODE_READ_TOO_LARGE,
79
+ f"{words} words requested; `tan probe read` returns at most {MAX_WORDS} "
80
+ "(1 KiB) per call",
81
+ )
82
+ if addr + 4 * words > 0x100000000:
83
+ raise ProbeArgError(CODE_BAD_ARGUMENT, "the read would run past the 32-bit address space")
84
+ return addr, words
85
+
86
+
87
+ def normalise_core(core: str | None) -> str | None:
88
+ if core is None:
89
+ return None
90
+ value = core.strip().lower().replace("-", "_")
91
+ if value not in CORES:
92
+ raise ProbeArgError(CODE_BAD_ARGUMENT, f"--core {core!r} is not one of {', '.join(CORES)}")
93
+ return value
94
+
95
+
96
+ def touches_unsafe_window(address: int, words: int) -> bool:
97
+ lo, hi = UNSAFE_WINDOW
98
+ return address <= hi and address + 4 * words - 1 >= lo
99
+
100
+
101
+ def window_refusal(address: int, words: int) -> str | None:
102
+ """The refusal for ANY overlap with 0x50000000-0x5FFFFFFF, on every core -- there is
103
+ no HP exception: a bench-proven-safe HP read is not something an attach check can
104
+ establish (a multiple-AP banner or `conflict-hp` still looks HP-ish), so the window
105
+ is simply not readable through `tan probe`."""
106
+ if not touches_unsafe_window(address, words):
107
+ return None
108
+ return (
109
+ f"refusing to read 0x{address:08X}..0x{address + 4 * words - 1:08X}: `tan probe read` "
110
+ "refuses 0x50000000-0x5FFFFFFF on every core. From the HE that window is the HP ITCM "
111
+ "alias (reading it leaves the core unhaltable until a PIN reset), and tan does not "
112
+ "read it from any attach."
113
+ )
114
+
115
+
116
+ def _slice_ambiguity(distinct: dict[str, set[str]]) -> str | None:
117
+ for key, values in distinct.items():
118
+ if len(values) > 1:
119
+ return (
120
+ f"the manifest's slices pin different {key} values ({', '.join(sorted(values))}); "
121
+ "pass --core to pick one, as `tan flash` requires"
122
+ )
123
+ return None
124
+
125
+
126
+ def select_slice(
127
+ slices: Sequence[tuple[str, dict]], core: str | None
128
+ ) -> tuple[dict, str | None, str | None, str | None]:
129
+ """`(flash_args, selected core id, ambiguity message, note)` from `(core_id, flash_args)` pairs.
130
+
131
+ The args are taken whether or not `expect_dpidr` is armed (`jlink_serial`,
132
+ `jlink_speed`, `jlink_device` apply either way). With `--core` the matching slice
133
+ wins (no match: `{}` plus a `note`); without it one slice is used as is, and several
134
+ are acceptable only when they do not disagree on `jlink_serial` / `expect_dpidr`
135
+ (then the selected id is unknown). A `jlink_serial` pinned by exactly one of them is
136
+ carried onto the chosen args."""
137
+ pool = [(c, a) for c, a in slices if isinstance(a, dict)]
138
+ if core is not None:
139
+ matched = [(c, a) for c, a in pool if c.lower() == core]
140
+ if pool and not matched:
141
+ return {}, None, None, f"the manifest has no slice for --core {core}; its settings were not used"
142
+ pool = matched
143
+ if not pool:
144
+ return {}, None, None, None
145
+ if len(pool) == 1:
146
+ return dict(pool[0][1]), pool[0][0].lower(), None, None
147
+ distinct = {
148
+ key: {str(a[key]) for _c, a in pool if a.get(key) is not None}
149
+ for key in ("jlink_serial", "expect_dpidr")
150
+ }
151
+ problem = _slice_ambiguity(distinct)
152
+ if problem:
153
+ return {}, None, problem, None
154
+ armed = [a for _c, a in pool if a.get("expect_dpidr")]
155
+ chosen = dict((armed or [a for _c, a in pool])[0])
156
+ if chosen.get("jlink_serial") is None and distinct["jlink_serial"]:
157
+ chosen["jlink_serial"] = next(iter(distinct["jlink_serial"]))
158
+ return chosen, None, None, None
159
+
160
+
161
+ def is_he_target(core: str | None, selected_id: str | None) -> bool:
162
+ """The target is the M55-HE: `--core m55_he`, or no `--core` and the manifest's
163
+ selected slice is the HE."""
164
+ return core == "m55_he" or (core is None and selected_id == "m55_he")
165
+
166
+
167
+ def itcm_check_allowed(target_he: bool, attach_verdict: str) -> bool:
168
+ """The `mem32 0x0` / `mem32 0x58000000` session is bench-proven only from an HE attach:
169
+ send it only when the target is the HE AND session 1's banner placed the attach on
170
+ the HE access port (exactly `he`), never on the claim alone."""
171
+ return target_he and attach_verdict == "he"
172
+
173
+
174
+ def itcm_skip_reason(core: str | None, selected_id: str | None, verdict: str) -> str:
175
+ """Why the ITCM corroboration was not run, and the fix, for the info issue."""
176
+ if core is None and selected_id is None:
177
+ return ("the target core is ambiguous (several manifest slices, or none, and no --core); "
178
+ "pass --core m55_he to corroborate an HE attach")
179
+ if not is_he_target(core, selected_id):
180
+ return (f"the target is {core or selected_id}, and the ITCM read is bench-proven only "
181
+ "from an HE attach; pass --core m55_he to corroborate an HE attach")
182
+ return (f"the connect banner placed the attach on '{verdict}', not the M55-HE access port, "
183
+ "so the HE-window read was not sent")
184
+
185
+
186
+ def claimed_core(core: str | None, selected_id: str | None) -> str | None:
187
+ """`--core`, else the manifest's selected slice when that is one of the known cores."""
188
+ return core or (selected_id if selected_id in CORES else None)
189
+
190
+
191
+ def core_contradiction(core: str | None, verdict: str) -> str | None:
192
+ """A message when the attach `verdict` (`ram_run` vocabulary, or the bare AP verdict)
193
+ contradicts the claimed `--core`; `None` when it agrees or says nothing."""
194
+ if core == "m55_he" and verdict == "conflict":
195
+ return f"--core m55_he, but the attach verdict is contradictory ({verdict})"
196
+ if core == "m55_he" and verdict in ("hp", "conflict-hp"):
197
+ return f"--core m55_he, but the probe attached to the M55-HP ({verdict})"
198
+ if core == "m55_hp" and verdict in ("he", "conflict"):
199
+ return f"--core m55_hp, but the probe attached to the M55-HE ({verdict})"
200
+ return None
201
+
202
+
203
+ def dpidr_state(expected: str | None, actual: str | None) -> str | None:
204
+ """`None` when no `expect_dpidr` is armed or it matches; `"unread"` / `"mismatch"` else."""
205
+ if not expected:
206
+ return None
207
+ if actual is None:
208
+ return "unread"
209
+ try:
210
+ return None if int(actual, 16) == int(expected, 16) else "mismatch"
211
+ except ValueError:
212
+ return "mismatch"
213
+
214
+
215
+ def identity_script(pre: Sequence[str]) -> str:
216
+ """The DP IDR read: exactly the Flow D / --ram DPIDR preflight --
217
+ `[SelectEmuBySN], si SWD, speed, device, connect, exit`."""
218
+ return "\n".join([*pre, "exit"]) + "\n"
219
+
220
+
221
+ def core_script(pre: Sequence[str]) -> str:
222
+ """The --ram attach check, verbatim (`connect` + two read-only `mem32`)."""
223
+ return core_check_script(pre)
224
+
225
+
226
+ def read_script(pre: Sequence[str], address: int, words: int) -> str:
227
+ """`connect`, ONE `mem32`, `exit`. Address and count are ints rendered with `0x%X`."""
228
+ return "\n".join([*pre, f"mem32 0x{address:X}, 0x{words:X}", "exit"]) + "\n"
229
+
230
+
231
+ def make_preamble(serial: str | None, speed: int, device: str = DEFAULT_DEVICE) -> list[str]:
232
+ return preamble(serial, speed, device)
233
+
234
+
235
+ def script_verbs(script: str) -> list[str]:
236
+ """The first token of every non-empty script line, lower-cased."""
237
+ return [ln.split()[0].lower() for ln in script.splitlines() if ln.split()]
tan/core/proxy.py ADDED
@@ -0,0 +1,168 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Which proxy environment variable applies to an `https://` request, whether
3
+ `NO_PROXY` exempts the target from it, and whether this interpreter can actually
4
+ route through the proxy that wins.
5
+
6
+ Port of `crates/tan-core/src/proxy.rs`, which is the frozen oracle's rule and
7
+ therefore the contract. Pure on purpose, for the same reason the Rust is:
8
+ reading the environment is IO and belongs at the call site (`sdk_cmd`), while
9
+ the *precedence rule* is a decision, and a decision with a scheme trap in it
10
+ deserves tests that never mutate the process environment.
11
+
12
+ **Why this module exists at all.** The port's one network call site used a bare
13
+ `urllib.request.urlopen`, which builds its proxy handler from
14
+ `getproxies_environment()`. That maps `ALL_PROXY`/`all_proxy` to the key `all`,
15
+ and `ProxyHandler` then installs an `all_open` method that `OpenerDirector._open`
16
+ -- which dispatches on `req.type`, i.e. `"https"` -- never calls. Measured
17
+ against the live GitHub Releases API: with `HTTPS_PROXY` pointing at a closed
18
+ port `tan sdk list --online` failed as it should (rc 1, `sdk.fetch-failed`,
19
+ `[Errno 111] Connection refused`); with `ALL_PROXY` pointing at that SAME closed
20
+ port and nothing else set, tan connected DIRECTLY and returned rc 0 with 13
21
+ releases. So a host whose only egress is `ALL_PROXY` -- the spelling curl, git
22
+ and the shipped Rust `tan` all honour -- was hard-broken, and a host with direct
23
+ egress silently circumvented the proxy it was told to use (tan-cli#497 defect 8).
24
+
25
+ **The precedence, MEASURED on the oracle rather than read off `proxy.rs`.** Two
26
+ listening sockets, `ALL_PROXY=http://127.0.0.1:45001` and
27
+ `HTTPS_PROXY=http://127.0.0.1:45002`, one `target/debug/tan sdk list`: the
28
+ connection landed on **45001**. `ALL_PROXY` outranks `HTTPS_PROXY`, and the
29
+ uppercase spelling outranks its lowercase alias. `HTTP_PROXY`/`http_proxy` are
30
+ deliberately ABSENT: curl, git and urllib all treat those as http-only, and
31
+ pushing an `https://` request through a plain-HTTP proxy that may refuse
32
+ `CONNECT` would break a corporate host that worked fine going direct.
33
+
34
+ **`NO_PROXY` is the other half of the same rule**, also measured: with
35
+ `ALL_PROXY` at a listening socket and `NO_PROXY=api.github.com`, the oracle went
36
+ direct and returned the release list (rc 0), and nothing ever reached the
37
+ socket. Honouring the proxy without the bypass list would proxy a host that was
38
+ deliberately exempted.
39
+
40
+ **SOCKS is where this port cannot follow the oracle.** `crates/tan-cli/src/http.rs`
41
+ compiles ureq's `socks-proxy` feature, and the oracle really does dial a
42
+ `socks5://` `ALL_PROXY` (measured: the listener on port 45012 was hit).
43
+ `urllib` has no SOCKS transport at all -- `ProxyHandler` would hand the request
44
+ back to `OpenerDirector` under the type `socks5` and produce `unknown url type`.
45
+ [`unsupported_proxy_scheme`] names that case so the call site can REFUSE with a
46
+ message that says what happened, rather than falling back to a direct
47
+ connection: silently bypassing a proxy the host mandated is the failure mode
48
+ this whole module exists to remove, and doing it as the *fix* would be worse
49
+ than the bug.
50
+ """
51
+
52
+ from __future__ import annotations
53
+
54
+ import os
55
+ from collections.abc import Callable
56
+
57
+ #: Proxy env vars that apply to an `https://` URL, in the oracle's own
58
+ #: precedence order (`ALL_PROXY` first, each lowercase alias immediately after
59
+ #: its uppercase form). `HTTP_PROXY`/`http_proxy` are deliberately absent -- see
60
+ #: the module docstring.
61
+ HTTPS_PROXY_ENV_VARS = ("ALL_PROXY", "all_proxy", "HTTPS_PROXY", "https_proxy")
62
+
63
+ #: Bypass-list env vars, uppercase first for the same reason. curl and git read
64
+ #: exactly these two.
65
+ NO_PROXY_ENV_VARS = ("NO_PROXY", "no_proxy")
66
+
67
+ #: URL schemes `urllib` can actually carry a proxied `https://` request over.
68
+ #: Anything else (`socks5`, `socks5h`, `socks4`, `socks4a`) needs a transport
69
+ #: this interpreter does not ship -- see [`unsupported_proxy_scheme`].
70
+ SUPPORTED_PROXY_SCHEMES = ("http", "https")
71
+
72
+
73
+ def _first_set(keys: tuple[str, ...], lookup: Callable[[str], str | None]) -> str | None:
74
+ """The first key with a non-blank value, or `None`.
75
+
76
+ An empty value counts as UNSET: exporting `HTTPS_PROXY=` is the conventional
77
+ way to disable an inherited proxy, and treating it as a proxy URL would make
78
+ every request fail.
79
+ """
80
+ for key in keys:
81
+ value = lookup(key)
82
+ if value is not None and value.strip():
83
+ return value
84
+ return None
85
+
86
+
87
+ def host_of(url: str) -> str:
88
+ """The host of an absolute URL -- no scheme, userinfo, port, path or
89
+ trailing root dot -- lowercased for comparison.
90
+
91
+ Deliberately not a URL parser: the one URL this path ever requests is one we
92
+ wrote ourselves (`sdk_cmd.GITHUB_RELEASES_URL`), so a parser would be more
93
+ moving parts than the two splits it replaces.
94
+ """
95
+ after_scheme = url.split("://", 1)[-1] if "://" in url else url
96
+ authority = after_scheme
97
+ for delimiter in ("/", "?", "#"):
98
+ authority = authority.split(delimiter, 1)[0]
99
+ host = authority.rsplit("@", 1)[-1]
100
+ return host.split(":", 1)[0].rstrip(".").lower()
101
+
102
+
103
+ def no_proxy_covers(list_: str, host: str) -> bool:
104
+ """Does a `NO_PROXY` list cover `host` (already lowercased by [`host_of`])?
105
+
106
+ The rules are curl's, git's and Python's, because those are what the user's
107
+ existing `NO_PROXY` was written against:
108
+
109
+ * `*` as an entry bypasses the proxy for everything.
110
+ * Comma-separated; surrounding whitespace and empty entries are ignored, so
111
+ `"a, ,b"` is two entries and a trailing comma is harmless.
112
+ * Case-insensitive on both sides -- DNS is.
113
+ * An entry matches the host exactly, or as a suffix **on a label boundary**:
114
+ `github.com` and `.github.com` both cover `api.github.com`, while
115
+ `hub.com` covers neither. A bare `endswith` is the classic bug here -- it
116
+ would let `hub.com` silently disable the proxy for `github.com`.
117
+ * A `:port` on an entry is ignored rather than honoured: every request on
118
+ this path is `https://` on 443, so a port-qualified entry can only mean
119
+ 443 anyway.
120
+ """
121
+ for raw in list_.split(","):
122
+ entry = raw.strip()
123
+ if entry == "*":
124
+ return True
125
+ entry = entry.split(":", 1)[0].strip(".").lower()
126
+ if not entry:
127
+ continue
128
+ if host == entry:
129
+ return True
130
+ if host.endswith(entry) and host[: -len(entry)].endswith("."):
131
+ return True
132
+ return False
133
+
134
+
135
+ def select_https_proxy(
136
+ target_url: str, lookup: Callable[[str], str | None] | None = None
137
+ ) -> str | None:
138
+ """The proxy URL an `https://` request to `target_url` should go through, or
139
+ `None` for direct.
140
+
141
+ `lookup` is the injected environment read, defaulting to `os.environ.get`.
142
+ Takes the whole URL rather than a pre-extracted host so that deriving the
143
+ host is covered by this module's tests too.
144
+ """
145
+ read = os.environ.get if lookup is None else lookup
146
+ proxy = _first_set(HTTPS_PROXY_ENV_VARS, read)
147
+ if proxy is None:
148
+ return None
149
+ bypass = _first_set(NO_PROXY_ENV_VARS, read)
150
+ if bypass is not None and no_proxy_covers(bypass, host_of(target_url)):
151
+ return None
152
+ return proxy.strip()
153
+
154
+
155
+ def unsupported_proxy_scheme(proxy_url: str) -> str | None:
156
+ """The scheme of `proxy_url` when `urllib` cannot route through it, else
157
+ `None`.
158
+
159
+ A bare `host:port` with no scheme is supported: `urllib._parse_proxy` treats
160
+ it as the request's own scheme, which is exactly the http(s) CONNECT path.
161
+ """
162
+ scheme, separator, _ = proxy_url.partition("://")
163
+ if not separator:
164
+ return None
165
+ scheme = scheme.lower()
166
+ if scheme in SUPPORTED_PROXY_SCHEMES:
167
+ return None
168
+ return scheme
tan/core/publish.py ADDED
@@ -0,0 +1,31 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Publish a finished temp file to its final name without ever overwriting or
3
+ following a link -- shared by `tan model add` and `tan model prep`."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ import shutil
9
+ from pathlib import Path
10
+
11
+
12
+ def publish_exclusive(tmp: Path, dest: Path) -> None:
13
+ """Move the verified temp file to `dest` without ever overwriting or
14
+ following a link: a hard link fails if `dest` exists (even dangling); on a
15
+ filesystem without hard links, `O_EXCL|O_NOFOLLOW` creates it instead.
16
+ Raises `FileExistsError` / `OSError`."""
17
+ try:
18
+ os.link(tmp, dest)
19
+ return
20
+ except FileExistsError:
21
+ raise
22
+ except (OSError, NotImplementedError):
23
+ pass
24
+ flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_BINARY", 0)
25
+ fd = os.open(dest, flags, 0o644)
26
+ try:
27
+ with open(tmp, "rb") as src, os.fdopen(fd, "wb") as out:
28
+ shutil.copyfileobj(src, out)
29
+ except BaseException:
30
+ dest.unlink(missing_ok=True)
31
+ raise