meltygui 0.1.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 (372) hide show
  1. meltygui/__init__.py +107 -0
  2. meltygui/accounts/__init__.py +0 -0
  3. meltygui/accounts/internet_accounts.py +1355 -0
  4. meltygui/chat/__init__.py +91 -0
  5. meltygui/chat/activity.py +75 -0
  6. meltygui/chat/backends.py +36 -0
  7. meltygui/chat/chat_interface.py +732 -0
  8. meltygui/chat/chat_proxy.py +352 -0
  9. meltygui/chat/codex_proxy.py +592 -0
  10. meltygui/chat/codex_settings.py +100 -0
  11. meltygui/chat/codex_transport.py +60 -0
  12. meltygui/chat/command_parser.py +204 -0
  13. meltygui/chat/images.py +227 -0
  14. meltygui/chat/messages.py +417 -0
  15. meltygui/chat/metadata.py +139 -0
  16. meltygui/chat/writer_locks.py +64 -0
  17. meltygui/code/__init__.py +0 -0
  18. meltygui/code/basic_converters.py +533 -0
  19. meltygui/code/chain_converters.py +2111 -0
  20. meltygui/code/code_checks.py +2209 -0
  21. meltygui/code/core_syntax.py +1430 -0
  22. meltygui/code/file_converters.py +1933 -0
  23. meltygui/code/fileref.py +702 -0
  24. meltygui/code/hotswap_guard.py +144 -0
  25. meltygui/code/libcst_conversion.py +9724 -0
  26. meltygui/code/live_instrument.py +392 -0
  27. meltygui/code/live_view.py +2490 -0
  28. meltygui/code/melty_scan.py +2684 -0
  29. meltygui/code/new_codecs.py +1255 -0
  30. meltygui/code/new_converters.py +3017 -0
  31. meltygui/code/project_code.py +278 -0
  32. meltygui/code/source_context.py +63 -0
  33. meltygui/code/symbol_roster.py +1588 -0
  34. meltygui/code/syntax_check.py +34 -0
  35. meltygui/code/syntax_check_worker.py +114 -0
  36. meltygui/completion/__init__.py +0 -0
  37. meltygui/completion/fim.py +1232 -0
  38. meltygui/completion/fim_context.py +481 -0
  39. meltygui/completion/providers/__init__.py +0 -0
  40. meltygui/completion/providers/anthropic_oauth.py +446 -0
  41. meltygui/completion/providers/anthropic_requests.py +69 -0
  42. meltygui/completion/providers/claude.py +203 -0
  43. meltygui/completion/providers/claude_usage.py +499 -0
  44. meltygui/completion/providers/codex_accounts.py +180 -0
  45. meltygui/completion/providers/copilot.py +617 -0
  46. meltygui/completion/providers/oauth_popup.py +220 -0
  47. meltygui/completion/providers/ollama.py +320 -0
  48. meltygui/completion/providers/profiles.py +23 -0
  49. meltygui/core/README.md +88 -0
  50. meltygui/core/__init__.py +1 -0
  51. meltygui/core/automation/__init__.py +1 -0
  52. meltygui/core/automation/action_core.py +153 -0
  53. meltygui/core/automation/collection_action.py +45 -0
  54. meltygui/core/automation/mcp_eval.py +167 -0
  55. meltygui/core/automation/mcp_hotswap.py +107 -0
  56. meltygui/core/automation/mcp_query.py +552 -0
  57. meltygui/core/automation/mcp_server.py +657 -0
  58. meltygui/core/automation/orchestration_core.py +2636 -0
  59. meltygui/core/automation/query_core.py +124 -0
  60. meltygui/core/automation/search_core.py +26 -0
  61. meltygui/core/automation/selector_core.py +340 -0
  62. meltygui/core/automation/value_core.py +1752 -0
  63. meltygui/core/cache/__init__.py +1 -0
  64. meltygui/core/cache/cache_diagnostics.py +0 -0
  65. meltygui/core/cache/invalidation_decoration.py +153 -0
  66. meltygui/core/cache/invalidation_tracker.py +43 -0
  67. meltygui/core/cache/tile_cache.py +5968 -0
  68. meltygui/core/conversion/__init__.py +1 -0
  69. meltygui/core/conversion/bubbling.py +599 -0
  70. meltygui/core/conversion/cache_tree.py +188 -0
  71. meltygui/core/conversion/chain.py +113 -0
  72. meltygui/core/conversion/converter_register.py +145 -0
  73. meltygui/core/conversion/data_decoration.py +67 -0
  74. meltygui/core/conversion/dict_conversion.py +1815 -0
  75. meltygui/core/conversion/dict_conversion_util.py +177 -0
  76. meltygui/core/conversion/dynamic_obj.py +89 -0
  77. meltygui/core/conversion/graph_compare.py +183 -0
  78. meltygui/core/conversion/load_save_v2.py +1032 -0
  79. meltygui/core/conversion/missing_saved_class.py +39 -0
  80. meltygui/core/conversion/path_finder.py +606 -0
  81. meltygui/core/conversion/render_host.py +1083 -0
  82. meltygui/core/core_render.py +6537 -0
  83. meltygui/core/definition_hotswap.py +231 -0
  84. meltygui/core/diagnostics/__init__.py +1 -0
  85. meltygui/core/diagnostics/attribute_churn.py +38 -0
  86. meltygui/core/diagnostics/fps_counter.py +39 -0
  87. meltygui/core/diagnostics/gpu_frame_timer.py +103 -0
  88. meltygui/core/diagnostics/inspection_core.py +169 -0
  89. meltygui/core/diagnostics/monitor_core.py +105 -0
  90. meltygui/core/diagnostics/notifications.py +706 -0
  91. meltygui/core/diagnostics/perf_trace.py +281 -0
  92. meltygui/core/diagnostics/profile_decoration.py +88 -0
  93. meltygui/core/diagnostics/resize_trace.py +62 -0
  94. meltygui/core/diagnostics/screenshot_core.py +247 -0
  95. meltygui/core/diagnostics/session_status.py +98 -0
  96. meltygui/core/diagnostics/trace_core.py +445 -0
  97. meltygui/core/files/__init__.py +1 -0
  98. meltygui/core/files/file_core.py +208 -0
  99. meltygui/core/files/file_explorer_core.py +104 -0
  100. meltygui/core/files/file_tree_core.py +198 -0
  101. meltygui/core/files/file_watch_core.py +43 -0
  102. meltygui/core/files/import_graph_core.py +43 -0
  103. meltygui/core/files/metadata_core.py +51 -0
  104. meltygui/core/graphics/__init__.py +1 -0
  105. meltygui/core/graphics/cuda_context_core.py +166 -0
  106. meltygui/core/graphics/cuda_interop_core.py +136 -0
  107. meltygui/core/graphics/cuda_kernel_core.py +91 -0
  108. meltygui/core/graphics/framebuffer_recorder.py +337 -0
  109. meltygui/core/graphics/gl_state.py +658 -0
  110. meltygui/core/graphics/lut_core.py +52 -0
  111. meltygui/core/graphics/overlay_renderer.py +984 -0
  112. meltygui/core/graphics/scene_target.py +180 -0
  113. meltygui/core/graphics/screenshot.py +439 -0
  114. meltygui/core/graphics/shader_func.py +478 -0
  115. meltygui/core/graphics/tensor_core.py +45 -0
  116. meltygui/core/graphics/text_texture.py +329 -0
  117. meltygui/core/graphics/wayland_color.py +635 -0
  118. meltygui/core/input/__init__.py +1 -0
  119. meltygui/core/input/collision.py +165 -0
  120. meltygui/core/input/drag_drop_core.py +1525 -0
  121. meltygui/core/input/hypr_left_drag.py +323 -0
  122. meltygui/core/input/input_core.py +245 -0
  123. meltygui/core/input/input_handler.py +1101 -0
  124. meltygui/core/input/mouse_cursor.py +355 -0
  125. meltygui/core/input/pynput_backend.py +1054 -0
  126. meltygui/core/input/space_mouse.py +338 -0
  127. meltygui/core/input/touchpad_backend.py +393 -0
  128. meltygui/core/input/view_selection.py +177 -0
  129. meltygui/core/layout/__init__.py +1 -0
  130. meltygui/core/layout/column_core.py +2153 -0
  131. meltygui/core/layout/cursor_core.py +161 -0
  132. meltygui/core/layout/dropdown_core.py +278 -0
  133. meltygui/core/layout/edge_constraints.py +155 -0
  134. meltygui/core/layout/grid_core.py +117 -0
  135. meltygui/core/layout/header_core.py +23 -0
  136. meltygui/core/layout/header_runtime.py +67 -0
  137. meltygui/core/layout/layout_core.py +87 -0
  138. meltygui/core/layout/tile_manager_core.py +591 -0
  139. meltygui/core/melty.py +6726 -0
  140. meltygui/core/module_map.json +896 -0
  141. meltygui/core/module_names.py +19 -0
  142. meltygui/core/rendering/__init__.py +1 -0
  143. meltygui/core/rendering/core_decoration.py +431 -0
  144. meltygui/core/rendering/core_render_helpers.py +328 -0
  145. meltygui/core/rendering/func_metadata.py +398 -0
  146. meltygui/core/rendering/mode.py +818 -0
  147. meltygui/core/rendering/mode_defaults.py +41 -0
  148. meltygui/core/rendering/modes.py +136 -0
  149. meltygui/core/rendering/parameter_core.py +1665 -0
  150. meltygui/core/rendering/render_dispatch.py +1891 -0
  151. meltygui/core/rendering/render_funcs.py +273 -0
  152. meltygui/core/rendering/shaped.py +312 -0
  153. meltygui/core/rendering/window_decoration.py +25 -0
  154. meltygui/core/runtime/__init__.py +1 -0
  155. meltygui/core/runtime/app.py +735 -0
  156. meltygui/core/runtime/app_session.py +140 -0
  157. meltygui/core/runtime/background.py +564 -0
  158. meltygui/core/runtime/extensions.py +53 -0
  159. meltygui/core/runtime/gc_manager.py +1163 -0
  160. meltygui/core/runtime/lifecycle.py +21 -0
  161. meltygui/core/runtime/paths.py +27 -0
  162. meltygui/core/runtime/settings.py +14 -0
  163. meltygui/core/runtime/singleton.py +16 -0
  164. meltygui/core/runtime/thread_safe_bool.py +24 -0
  165. meltygui/core/runtime/thread_signal.py +30 -0
  166. meltygui/core/runtime/toggles.py +3115 -0
  167. meltygui/core/services/__init__.py +1 -0
  168. meltygui/core/services/account_core.py +10 -0
  169. meltygui/core/services/chat_core.py +19 -0
  170. meltygui/core/services/claude_terminal_core.py +346 -0
  171. meltygui/core/services/terminal_core.py +458 -0
  172. meltygui/core/services/terminal_runtime.py +78 -0
  173. meltygui/core/styling/__init__.py +1 -0
  174. meltygui/core/styling/color_core.py +46 -0
  175. meltygui/core/styling/fonts.py +639 -0
  176. meltygui/core/styling/global_style.py +338 -0
  177. meltygui/core/styling/style.py +198 -0
  178. meltygui/core/styling/style_core.py +522 -0
  179. meltygui/core/styling/warm_start.py +149 -0
  180. meltygui/core/windowing/__init__.py +1 -0
  181. meltygui/core/windowing/backends/PYIMGUI_LICENSE +28 -0
  182. meltygui/core/windowing/backends/__init__.py +1 -0
  183. meltygui/core/windowing/backends/imgui_renderer.py +138 -0
  184. meltygui/core/windowing/backends/native_wayland.py +850 -0
  185. meltygui/core/windowing/backends/protocols/xdg-decoration-unstable-v1.xml +156 -0
  186. meltygui/core/windowing/backends/protocols/xdg-shell.xml +1420 -0
  187. meltygui/core/windowing/backends/wayland_protocol.py +101 -0
  188. meltygui/core/windowing/dock_core.py +182 -0
  189. meltygui/core/windowing/frame_geometry.py +42 -0
  190. meltygui/core/windowing/geometry_feed.py +864 -0
  191. meltygui/core/windowing/glfw_utils.py +1343 -0
  192. meltygui/core/windowing/os_frame.py +1552 -0
  193. meltygui/core/windowing/surface.py +643 -0
  194. meltygui/core/windowing/titlebar.py +1560 -0
  195. meltygui/core/windowing/titlebar_buttons.py +281 -0
  196. meltygui/core/windowing/wayland_move.py +932 -0
  197. meltygui/core/windowing/window_api.py +62 -0
  198. meltygui/core/windowing/window_constants.py +339 -0
  199. meltygui/core/windowing/window_visibility.py +162 -0
  200. meltygui/debug/__init__.py +0 -0
  201. meltygui/debug/app_view_utils.py +9 -0
  202. meltygui/editor/__init__.py +0 -0
  203. meltygui/editor/bash_syntax.py +30 -0
  204. meltygui/editor/code_line_fast.py +152 -0
  205. meltygui/editor/diff.py +139 -0
  206. meltygui/editor/external_changes.py +159 -0
  207. meltygui/editor/file_header.py +41 -0
  208. meltygui/editor/live_usage.py +169 -0
  209. meltygui/editor/live_view_views.py +1803 -0
  210. meltygui/editor/pending_save.py +1351 -0
  211. meltygui/editor/roster_tints.py +547 -0
  212. meltygui/editor/source_preview.py +16 -0
  213. meltygui/editor/source_tools.py +10 -0
  214. meltygui/editor/source_ui.py +70 -0
  215. meltygui/editor/spell_check.py +77 -0
  216. meltygui/editor/text_editor.py +9076 -0
  217. meltygui/editor/usage_picker.py +538 -0
  218. meltygui/events/__init__.py +0 -0
  219. meltygui/events/example.py +118 -0
  220. meltygui/examples/__init__.py +0 -0
  221. meltygui/examples/columns_demo.py +33 -0
  222. meltygui/examples/columns_window_demo.py +60 -0
  223. meltygui/examples/context_menu_demo.py +24 -0
  224. meltygui/examples/context_menu_window_demo.py +49 -0
  225. meltygui/examples/gui_playground.py +127 -0
  226. meltygui/examples/live_view_playground.py +256 -0
  227. meltygui/examples/lora.py +43 -0
  228. meltygui/examples/lora_data.py +59 -0
  229. meltygui/examples/lora_policies.py +67 -0
  230. meltygui/examples/mode_demo.py +49 -0
  231. meltygui/examples/modifies_demo.py +119 -0
  232. meltygui/examples/scalar_policies.py +60 -0
  233. meltygui/examples/style_layouts.py +149 -0
  234. meltygui/examples/tile_manager_demo.py +74 -0
  235. meltygui/examples/tint_demo.py +160 -0
  236. meltygui/examples/tint_functions.py +56 -0
  237. meltygui/examples/trace_demo.py +88 -0
  238. meltygui/examples/two_windows.py +36 -0
  239. meltygui/files/__init__.py +0 -0
  240. meltygui/files/fast_file_explorer.py +439 -0
  241. meltygui/gnome_extension/lsd-window-geometry@latent-descent/extension.js +237 -0
  242. meltygui/gnome_extension/lsd-window-geometry@latent-descent/lsd-window-geometry@latent-descent.iml +9 -0
  243. meltygui/gnome_extension/lsd-window-geometry@latent-descent/metadata.json +7 -0
  244. meltygui/graphics/__init__.py +6 -0
  245. meltygui/graphics/base.py +85 -0
  246. meltygui/graphics/examples.py +507 -0
  247. meltygui/graphics/executor.py +520 -0
  248. meltygui/graphics/filter.py +667 -0
  249. meltygui/graphics/filter.pyi +856 -0
  250. meltygui/graphics/generate_stubs.py +22 -0
  251. meltygui/graphics/registry.py +203 -0
  252. meltygui/graphics/shader_compiler.py +155 -0
  253. meltygui/graphics/shaders.py +1302 -0
  254. meltygui/graphics/stub_generator.py +250 -0
  255. meltygui/graphics/texture_manager.py +170 -0
  256. meltygui/graphics/texture_min_max.py +295 -0
  257. meltygui/hdr_color.py +757 -0
  258. meltygui/image_load.py +308 -0
  259. meltygui/model/__init__.py +1 -0
  260. meltygui/model/account_model.py +142 -0
  261. meltygui/model/camera_model.py +159 -0
  262. meltygui/model/chat_model.py +86 -0
  263. meltygui/model/code_model.py +62 -0
  264. meltygui/model/code_proxy_model.py +876 -0
  265. meltygui/model/collection_model.py +41 -0
  266. meltygui/model/color_model.py +127 -0
  267. meltygui/model/cuda_tensor_model.py +36 -0
  268. meltygui/model/cuda_texture_model.py +149 -0
  269. meltygui/model/dropdown_model.py +137 -0
  270. meltygui/model/file_metadata_model.py +73 -0
  271. meltygui/model/file_model.py +289 -0
  272. meltygui/model/format_model.py +390 -0
  273. meltygui/model/graph_model.py +98 -0
  274. meltygui/model/icon_model.py +1024 -0
  275. meltygui/model/import_graph_model.py +468 -0
  276. meltygui/model/layout_model.py +15 -0
  277. meltygui/model/lut_model.py +266 -0
  278. meltygui/model/search_model.py +173 -0
  279. meltygui/model/tensor_model.py +402 -0
  280. meltygui/model/terminal_model.py +329 -0
  281. meltygui/model/texture_model.py +146 -0
  282. meltygui/model/tile_model.py +24 -0
  283. meltygui/model/trace_model.py +198 -0
  284. meltygui/model/trace_report_model.py +146 -0
  285. meltygui/models/__init__.py +0 -0
  286. meltygui/models/file_meta.py +613 -0
  287. meltygui/models/function_console.py +184 -0
  288. meltygui/models/orchestration.py +48 -0
  289. meltygui/pbr.py +1576 -0
  290. meltygui/png_unfilter.c +49 -0
  291. meltygui/resources/JetBrainsMono-Regular.ttf +0 -0
  292. meltygui/resources/THIRD_PARTY_NOTICES.md +21 -0
  293. meltygui/resources/dejavu/DejaVuSans-Bold.ttf +0 -0
  294. meltygui/resources/dejavu/DejaVuSans-ExtraLight.ttf +0 -0
  295. meltygui/resources/dejavu/DejaVuSans.ttf +0 -0
  296. meltygui/resources/dejavu/LICENSE.txt +78 -0
  297. meltygui/resources/fontawesome-LICENSE.txt +121 -0
  298. meltygui/resources/fontawesome-webfont.ttf +0 -0
  299. meltygui/resources/hdri/studio_small_09_1k.hdr +0 -0
  300. meltygui/resources/jetbrains-weights/JetBrainsMono-Bold.ttf +0 -0
  301. meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraBold.ttf +0 -0
  302. meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraLight.ttf +0 -0
  303. meltygui/resources/jetbrains-weights/JetBrainsMono-Light.ttf +0 -0
  304. meltygui/resources/jetbrains-weights/JetBrainsMono-Medium.ttf +0 -0
  305. meltygui/resources/jetbrains-weights/JetBrainsMono-SemiBold.ttf +0 -0
  306. meltygui/resources/jetbrains-weights/JetBrainsMono-Thin.ttf +0 -0
  307. meltygui/resources/jetbrains-weights/OFL.txt +93 -0
  308. meltygui/resources/jetbrains-weights/README.md +2 -0
  309. meltygui/state/__init__.py +0 -0
  310. meltygui/state/account_state.py +22 -0
  311. meltygui/state/animation_state.py +97 -0
  312. meltygui/state/annotation_state.py +22 -0
  313. meltygui/state/chat_state.py +36 -0
  314. meltygui/state/code_state.py +10 -0
  315. meltygui/state/core_enums.py +59 -0
  316. meltygui/state/core_markers.py +115 -0
  317. meltygui/state/core_undo.py +1075 -0
  318. meltygui/state/file_state.py +75 -0
  319. meltygui/state/graph_state.py +26 -0
  320. meltygui/state/inspection_state.py +47 -0
  321. meltygui/state/menu_state.py +10 -0
  322. meltygui/state/model_enums.py +11 -0
  323. meltygui/state/new_core_model.py +2394 -0
  324. meltygui/state/orchestration_state.py +14 -0
  325. meltygui/state/query_state.py +24 -0
  326. meltygui/state/tensor_state.py +10 -0
  327. meltygui/state/terminal_state.py +23 -0
  328. meltygui/state/trace_state.py +32 -0
  329. meltygui/state/voxel_state.py +12 -0
  330. meltygui/text_index.py +816 -0
  331. meltygui/utils/__init__.py +0 -0
  332. meltygui/utils/jump_to_code.py +344 -0
  333. meltygui/utils/pkl_inspect.py +90 -0
  334. meltygui/utils/render_utils.py +1419 -0
  335. meltygui/view/__init__.py +1 -0
  336. meltygui/view/account_view.py +524 -0
  337. meltygui/view/action_view.py +73 -0
  338. meltygui/view/chat_decoration_view.py +112 -0
  339. meltygui/view/chat_view.py +1703 -0
  340. meltygui/view/code_view.py +2677 -0
  341. meltygui/view/collection_view.py +1185 -0
  342. meltygui/view/color_view.py +824 -0
  343. meltygui/view/control_view.py +559 -0
  344. meltygui/view/decoration_view.py +439 -0
  345. meltygui/view/diagnostic_view.py +177 -0
  346. meltygui/view/dropdown_view.py +1082 -0
  347. meltygui/view/file_view.py +1599 -0
  348. meltygui/view/graph_cuda_view.py +105 -0
  349. meltygui/view/graph_view.py +882 -0
  350. meltygui/view/header_view.py +848 -0
  351. meltygui/view/input_view.py +238 -0
  352. meltygui/view/inspection_view.py +1783 -0
  353. meltygui/view/layout_view.py +596 -0
  354. meltygui/view/lut_view.py +45 -0
  355. meltygui/view/menu_view.py +212 -0
  356. meltygui/view/orchestration_view.py +658 -0
  357. meltygui/view/query_view.py +118 -0
  358. meltygui/view/search_view.py +342 -0
  359. meltygui/view/tab_view.py +258 -0
  360. meltygui/view/tensor_view.py +430 -0
  361. meltygui/view/terminal_view.py +355 -0
  362. meltygui/view/text_view.py +8031 -0
  363. meltygui/view/texture_view.py +480 -0
  364. meltygui/view/tile_view.py +38 -0
  365. meltygui/view/trace_view.py +923 -0
  366. meltygui/view/voxel_cuda_view.py +824 -0
  367. meltygui/view/voxel_view.py +1860 -0
  368. meltygui/view/window_view.py +111 -0
  369. meltygui-0.1.0.dist-info/METADATA +143 -0
  370. meltygui-0.1.0.dist-info/RECORD +372 -0
  371. meltygui-0.1.0.dist-info/WHEEL +4 -0
  372. meltygui-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,2490 @@
1
+ """live_view() — capture a running local and key it by its CST-dict address.
2
+
3
+ User code drops a bare `live_view()` after an assignment (or `live_view(expr)`
4
+ anywhere) in a function body. At runtime the call resolves its own site to the
5
+ SAME key the libcst→dict conversion gives that statement — `("if##0",
6
+ "live_view()")`, not a line number — reads the preceding local for the bare
7
+ form, and stores the value RAW on the OWNING FUNCTION object under
8
+ `__live_values__`. The editor reads the store off the object it is editing
9
+ (`live_values_for`) and renders each value as a nested window anchored to the
10
+ call's inline token, so the value and the code that produced it stay linked.
11
+
12
+ Why CST keys, not line numbers: the keys (`x`, `x#1`, `live_view()#N`,
13
+ `if##0`) are structural — they survive the line drift between code that is
14
+ RUNNING (an old frame in a training loop) and source that is being edited.
15
+ Keys are relative to the STORE OBJECT's scope (the segments after the owning
16
+ def's own "locals"), so a nested def's sites stay distinct on the outer
17
+ function they attach to, and the editor's span parse sees the same paths.
18
+ Statements the dict conversion doesn't surface (while/with/match bodies,
19
+ nested defs) fall back to a `line:N`-qualified key — still distinct per site,
20
+ just line-stable instead of edit-stable.
21
+
22
+ Why the function object: hotswap mutates `func.__code__` in place
23
+ (file_converters._recompile) and never rebinds the module var, so the function
24
+ object — and this store — keeps its identity across recompiles. The capture
25
+ side and the editor side converge on the same object through the same
26
+ resolver (`_enclosing_function`). Class-body and module-level sites attach to
27
+ the module instead (a class-body frame is not CO_OPTIMIZED — see
28
+ _resolve_site).
29
+
30
+ Two parses, both bounded: a C-speed `ast` parse of the file finds the
31
+ enclosing top-level statement and the bare form's preceding assignment (ast
32
+ sees aug-assigns, tuple unpacks, while/with bodies — everything the display
33
+ dict elides), then the libcst→dict pass runs on JUST that statement's span, so
34
+ a save of a large file costs O(function), not the whole-file position pass
35
+ that chain_converters measured at ~1s of GIL stall.
36
+
37
+ Capture must be hot-loop cheap: everything line-dependent is resolved ONCE per
38
+ (code object, line) and cached BY CODE OBJECT IDENTITY — the steady-state path
39
+ is two dict lookups, no stat / parse / sys.modules walk. A hotswap installs a
40
+ new code object, whose first live_view call re-resolves against the file; the
41
+ old code object's sites evict via weakref.finalize.
42
+
43
+ Known limit: a frame still executing OLD code after a hotswap-without-save
44
+ reports linenos that may not match the file on disk, so a site first resolved
45
+ after that point can mis-key until the function re-enters. Sites resolved
46
+ before the swap are cached and stay correct.
47
+
48
+ Loop sites ACCUMULATE: a site enclosed by for/while loops (statically known —
49
+ from the injected snapshot's loop context or the site resolution's own ast
50
+ walk) folds each publish into a per-key accumulator instead of overwriting.
51
+ Tensors/ndarrays append along a new leading dim per enclosing loop, auto-named
52
+ from the loop variable ('l_idx' for `for l_idx, layer in enumerate(…)`);
53
+ everything else appends to a list. See _accumulate for the growth-buffer,
54
+ rollover, and nested-grid mechanics, and __live_dim_names__ /
55
+ auto_dim_names_for for how the editor marker merges the auto names into the
56
+ value window's dim_names.
57
+ """
58
+
59
+ import ast
60
+ import inspect
61
+ import itertools
62
+ import sys
63
+ import threading
64
+ import time
65
+ import types
66
+ import weakref
67
+ from contextlib import contextmanager
68
+ from pathlib import Path
69
+
70
+ _MISSING = object()
71
+
72
+ # Guards _sites entry creation only. Parses run OUTSIDE any lock - two threads
73
+ # racing a first call may duplicate a parse (benign, identical results) instead
74
+ # of one file's parse blocking every other file's resolution.
75
+ _lock = threading.Lock()
76
+
77
+ # id(code object) -> {lineno: _Site or None}. Identity-keyed, NOT a
78
+ # WeakKeyDictionary - code objects compare equal across files (co_filename is
79
+ # excluded from code equality), so identity keying aliases same-source
80
+ # functions in different files onto one site. A weakref.finalize evicts the
81
+ # entry when the code object dies - hotswap gives the function a new __code__,
82
+ # the old one's sites evict themselves. (A None site marks a failed
83
+ # resolution, so it never re-parses per call.)
84
+ _sites = {}
85
+
86
+ # Every object that ever owned a live store (functions, the def-run path's
87
+ # parked twins, ...). Weak: a store owner that dies takes its store with it.
88
+ # The CUDA OOM responder (gc_manager.respond_to_cuda_oom) sweeps these -
89
+ # nothing else enumerates stores, they are found by resolution.
90
+ _store_owners = globals().get("_store_owners")
91
+ if _store_owners is None:
92
+ _store_owners = weakref.WeakSet()
93
+
94
+ # str(path) -> (mtime, ast.Module, source text). One C-speed parse per file
95
+ # version, shared by span lookup and previous-assignment resolution.
96
+ _asts = {}
97
+
98
+ # (str(path), span start line) -> (mtime, LineMap). One libcst→dict pass per
99
+ # top-level statement (def/class) version - function-size, latest mtime wins.
100
+ _linemaps = {}
101
+
102
+ # (str(path), lineno) -> (sig, (store_obj, key_path)). site_for_line memo -
103
+ # the editor overlay resolves every visible live_view token per repaint, and
104
+ # the chain under it (enclosing-def walk, file parse, linemap) is
105
+ # once-per-edit slow. Keyed on _ast_for's (mtime, pending_edit) sig; negative
106
+ # results cached too (a token with no surfaced site would otherwise re-walk
107
+ # per repaint).
108
+ _site_for_line_cache = {}
109
+
110
+
111
+ class _Site:
112
+ """Everything line-dependent about one live_view call site, resolved once.
113
+ `key_path` is relative to `store_obj`'s scope; `var_name` labels (and for
114
+ the bare form, selects) the captured local; `arg_label` is the explicit
115
+ form's argument source text; `loop_dims` names the enclosing loops
116
+ (outermost first — see _loop_dims_at), which turns the site's publishes
117
+ into accumulation instead of overwrite."""
118
+ __slots__ = ("key_path", "var_name", "arg_label", "store_obj", "lineno",
119
+ "loop_dims")
120
+
121
+ def __init__(self, key_path, var_name, arg_label, store_obj, lineno,
122
+ loop_dims=()):
123
+ self.key_path = key_path
124
+ self.var_name = var_name
125
+ self.arg_label = arg_label
126
+ self.store_obj = store_obj
127
+ self.lineno = lineno
128
+ self.loop_dims = loop_dims
129
+
130
+
131
+ def live_view(value=_MISSING, name=None):
132
+ """Publish a live value for the editor, keyed by this call's CST address.
133
+
134
+ Bare form — `live_view()` — captures the variable assigned by the
135
+ PRECEDING statement (aug-assigns and block bodies included; resolved from
136
+ the ast, not the display dict). Explicit form — `live_view(expr)` —
137
+ captures expr and passes it through, so `y = live_view(f(x))` works
138
+ inline. A site inside a loop ACCUMULATES instead of overwriting — see
139
+ _accumulate; the loop variables' current values are read from the calling
140
+ frame so int-indexed loops key entries by iteration. Never raises into
141
+ the caller; an unresolvable site is recorded once and skipped
142
+ thereafter."""
143
+ frame = sys._getframe(1)
144
+ try:
145
+ site = _site_for(frame.f_code, frame.f_lineno)
146
+ if site is None or site.store_obj is None:
147
+ return None if value is _MISSING else value
148
+ bare = value is _MISSING
149
+ if bare:
150
+ if site.var_name is None:
151
+ return None
152
+ resolved = frame.f_locals.get(site.var_name, _MISSING)
153
+ if resolved is _MISSING:
154
+ return None
155
+ else:
156
+ resolved = value
157
+ dims = getattr(site, "loop_dims", ()) or None
158
+ idx = (tuple(frame.f_locals.get(d) for d in dims)
159
+ if dims else None)
160
+ _publish(site, resolved, name, bare, dims=dims, idx=idx)
161
+ return resolved
162
+ finally:
163
+ del frame
164
+
165
+
166
+ def twin_snap(value, name=None, dims=None):
167
+ """The instrumented twin's injected snapshot hook (`__lv_view__`) — the
168
+ FAST publish path. Synthesizes the same ``line:N#name`` keys the
169
+ frame-snapshot publisher uses (the overlay anchors line keys by line and
170
+ boxes by label), so a twin run does NO site resolution: the classic path
171
+ pays a libcst linemap per enclosing span (~240ms for a big function,
172
+ the whole CLASS for a method) on every fresh twin code object — i.e.
173
+ after every edit. Store resolution is the cached _enclosing_function
174
+ walk. Sharing the frame-snapshot key scheme also means a twin run and a
175
+ context-menu snapshot UPDATE THE SAME MARKERS instead of doubling them.
176
+ Manual live_view() calls in the body keep the structural-key path (their
177
+ markers anchor to the call token). Falls back to the classic resolved
178
+ path when the store can't be resolved.
179
+
180
+ `dims` is the instrumentation's STATIC loop context — the names of the
181
+ loops enclosing the snapped assignment, outermost first (see
182
+ live_instrument._inject_snaps). When present, the loop variables' current
183
+ values are read from the twin frame and the publish ACCUMULATES (stacked
184
+ tensors / lists) instead of overwriting — see _accumulate."""
185
+ frame = sys._getframe(1)
186
+ try:
187
+ code, lineno = frame.f_code, frame.f_lineno
188
+ idx = (tuple(frame.f_locals.get(d) for d in dims)
189
+ if dims else None)
190
+ finally:
191
+ del frame
192
+ dims = tuple(dims) if dims else None
193
+ try:
194
+ # The run's own store comes first (run_capture on this file, same
195
+ # file): the prune at run end and the accumulator's fresh-run reset
196
+ # both key on that object, so publishing anywhere else leaks.
197
+ fn = current_run_owner()
198
+ try:
199
+ same_file = (fn is not None and isinstance(fn, types.FunctionType)
200
+ and fn.__code__.co_filename == code.co_filename)
201
+ except Exception:
202
+ same_file = False
203
+ if not same_file:
204
+ from meltygui.code.chain_converters import _enclosing_function
205
+ fn = _enclosing_function(code.co_filename, lineno)
206
+ if (isinstance(fn, types.FunctionType)
207
+ and isinstance(name, str) and name.isidentifier()):
208
+ site = _Site((f"line:{lineno}#{name}",), None, None, fn, lineno)
209
+ _publish(site, value, name, bare=False, dims=dims, idx=idx)
210
+ return value
211
+ except Exception:
212
+ pass
213
+ site = _site_for(code, lineno)
214
+ if site is not None and site.store_obj is not None:
215
+ _publish(site, value, name, bare=False, dims=dims, idx=idx)
216
+ return value
217
+
218
+
219
+ def twin_ret(value=None):
220
+ """The instrumented twin's return hook (`__lv_ret__`): every `return X`
221
+ in the twin compiles as `return __lv_ret__(X)` (bare `return` passes
222
+ None), so the calling frame's f_lineno IS the return statement's
223
+ original file line. Stamps `__live_return_line__` ((absolute 1-indexed
224
+ file line, run-time line text)) on the resolved store function and wakes the store-level
225
+ watchers, so the editor's snapshot overlay can wash that line green —
226
+ the success twin of the red error-line wash — the moment the run comes
227
+ out. run_instrumented clears the stamp at run start, so a raise (or an
228
+ edit that removes the return) never leaves a stale green line. Returns
229
+ the value unchanged."""
230
+ frame = sys._getframe(1)
231
+ try:
232
+ code, lineno = frame.f_code, frame.f_lineno
233
+ finally:
234
+ del frame
235
+ try:
236
+ from meltygui.code.chain_converters import _enclosing_function
237
+ fn = _enclosing_function(code.co_filename, lineno)
238
+ if isinstance(fn, types.FunctionType):
239
+ stamp_run_marker(fn, "__live_return_line__",
240
+ (lineno, _line_text_at(fn, lineno)))
241
+ except Exception:
242
+ pass
243
+ return value
244
+
245
+
246
+ def _line_text_at(fn, lineno):
247
+ """The run-time text of absolute file line `lineno` in fn's file — read
248
+ from the same cached in-memory build the twin compiles against (disk +
249
+ pending splices, via _ast_for), so it costs a dict hit per run. Stamped
250
+ alongside the exit-line numbers so the overlay can re-find the line by
251
+ CONTENT after later edits shift it — the wash follows its statement the
252
+ way line-keyed markers follow their symbols. Never raises; None when
253
+ unresolvable."""
254
+ try:
255
+ path = Path(inspect.unwrap(fn).__code__.co_filename).resolve()
256
+ _tree, text, _sig = _ast_for(path, path.stat().st_mtime)
257
+ lines = text.split("\n")
258
+ if 1 <= lineno <= len(lines):
259
+ return lines[lineno - 1]
260
+ except Exception:
261
+ pass
262
+ return None
263
+
264
+
265
+ def stamp_run_marker(fn, attr, value):
266
+ """Stamp a per-run marker attribute (`__live_return_line__`,
267
+ `__live_error_line__`) on a store function and wake its store-level
268
+ watchers (the snapshot editors) with the same throttled render wake the
269
+ publish path uses, so the editor washes appear without an unrelated
270
+ repaint. Safe from any thread; swallows everything — markers are
271
+ decoration, never worth breaking a run over."""
272
+ try:
273
+ vars(fn)[attr] = value
274
+ notified = False
275
+ try:
276
+ targets = tuple(
277
+ getattr(fn, "__live_store_watchers__", None) or ())
278
+ except RuntimeError:
279
+ targets = ()
280
+ for ds in targets:
281
+ try:
282
+ ds.invalidate()
283
+ notified = True
284
+ except Exception:
285
+ pass
286
+ if notified:
287
+ global _last_wake
288
+ now = time.time()
289
+ if now - _last_wake > 0.033:
290
+ _last_wake = now
291
+ from meltygui.core.windowing.glfw_utils import request_render
292
+ request_render()
293
+ except Exception:
294
+ pass
295
+
296
+
297
+ def call_with_body_capture(func, kwargs, on_captured=None):
298
+ """Call ``func(**kwargs)`` and capture its body frame's locals at return,
299
+ handing them to the async frame-snapshot publisher.
300
+
301
+ This is the context menu's one-shot answer to "what are the TARGET's
302
+ mid-body locals": the wrapper's stack capture runs before the body
303
+ executes, so entry kwargs were all it could publish and the func tab
304
+ showed markers only on the signature. A per-thread profile hook watches
305
+ for the target code's outermost 'return' (depth-tracked, so a
306
+ self-recursive view captures the WIDGET's frame, not an inner one);
307
+ profiling covers only this one call's subtree, armed on menu-open only —
308
+ never steady-state. An exception unwind still fires the profile return
309
+ event, so a crashed body publishes its state at the raise."""
310
+ inner = inspect.unwrap(func)
311
+ target_code = getattr(inner, "__code__", None)
312
+ if target_code is None:
313
+ return func(**kwargs)
314
+ captured = {}
315
+ exit_line = [None]
316
+ depth = [0]
317
+
318
+ def _finish():
319
+ if captured:
320
+ # The GLOBAL half (return-line wash + frame-snapshot publish)
321
+ # stays behind the live-view toggle, exactly as before. The
322
+ # LOCAL half - `on_captured` (the context menu's CodeEditor
323
+ # adopting the body locals into its own stack copy) - always
324
+ # fires; it touches nothing global.
325
+ from meltygui.core.runtime.toggles import Toggles
326
+ if Toggles.TextEditor.enable_live_view:
327
+ if exit_line[0] is not None:
328
+ stamp_run_marker(inner, "__live_return_line__",
329
+ (exit_line[0],
330
+ _line_text_at(inner, exit_line[0])))
331
+ publish_stack_locals((), extra_snapshots=[(inner, captured,
332
+ None)])
333
+ if on_captured is not None:
334
+ try:
335
+ on_captured(dict(captured))
336
+ except Exception:
337
+ pass
338
+
339
+ # sys.monitoring with LOCAL events on just the target code object: the
340
+ # old sys.setprofile hook fired a Python callback for EVERY call/return
341
+ # in the whole call subtree, running a big view took under took
342
+ # SECONDS per capture (the 12s editor frames when the context menu's
343
+ # capture re-ran draw_text). Local events instrument only target_code,
344
+ # so the subtree runs at full speed and only pay one callback per
345
+ # entry/exit of the target itself.
346
+ def _grab(offset_frames):
347
+ try:
348
+ f = sys._getframe(offset_frames)
349
+ if f.f_code is target_code:
350
+ captured.update(f.f_locals)
351
+ # The frame's line AT the callback is the return statement
352
+ # itself (or the raise, on unwind) - the same
353
+ # __live_return_line__ the instrumented twin stamps.
354
+ exit_line[0] = f.f_lineno
355
+ except Exception:
356
+ pass
357
+
358
+ mon = getattr(sys, "monitoring", None)
359
+ if mon is not None:
360
+ try:
361
+ mon.use_tool_id(mon.PROFILER_ID, "lsd-live-capture")
362
+ except Exception:
363
+ mon = None # tool slot busy - fall back to setprofile
364
+ if mon is not None:
365
+ def _on_start(code, off):
366
+ depth[0] += 1
367
+
368
+ def _on_return(code, off, retval):
369
+ depth[0] -= 1
370
+ if depth[0] <= 0 and not captured:
371
+ _grab(2) # callback ← returning target frame
372
+
373
+ # PY_UNWIND cannot be local to a code object (only PY_START/PY_RETURN
374
+ # are local-able events — 0x1005 is rejected), so an exception unwind
375
+ # publishes nothing here. That loses the old profile hook's
376
+ # capture-at-raise, which is acceptable: the instrumented-twin path
377
+ # stamps raise lines through its own machinery, and a crashed body
378
+ # capture just stays un-published.
379
+ try:
380
+ ev = mon.events
381
+ mon.register_callback(mon.PROFILER_ID, ev.PY_START, _on_start)
382
+ mon.register_callback(mon.PROFILER_ID, ev.PY_RETURN, _on_return)
383
+ mon.set_local_events(mon.PROFILER_ID, target_code,
384
+ ev.PY_START | ev.PY_RETURN)
385
+ return func(**kwargs)
386
+ finally:
387
+ try:
388
+ mon.set_local_events(mon.PROFILER_ID, target_code, 0)
389
+ for _e in (mon.events.PY_START, mon.events.PY_RETURN):
390
+ mon.register_callback(mon.PROFILER_ID, _e, None)
391
+ mon.free_tool_id(mon.PROFILER_ID)
392
+ except Exception:
393
+ pass
394
+ _finish()
395
+
396
+ # Monitoring unavailable (tool id busy, e.g. a debugger owns it): return
397
+ # plainly rather than fall back to the old whole-subtree sys.setprofile
398
+ # hook, which cost SECONDS for a big view body. The capture (and its
399
+ # return-line wash) just doesn't happen for this call.
400
+ return func(**kwargs)
401
+
402
+
403
+ def live_values_for(obj):
404
+ """A SNAPSHOT {key_path: value} of a function/module's store, or {}. The
405
+ store holds the captured values RAW — no record wrapper — so a reader
406
+ hands them straight to draw_any and the framework routes by type. Copied
407
+ so a render-thread iteration can't race a training-thread insert. Looks
408
+ through decorator wrappers — capture attaches to the UNWRAPPED function,
409
+ the object whose __code__ hotswap mutates."""
410
+ try:
411
+ obj = inspect.unwrap(obj)
412
+ except Exception:
413
+ pass
414
+ store = getattr(obj, "__live_values__", None)
415
+ return dict(store) if store else {}
416
+
417
+
418
+ def label_for(obj, key_path):
419
+ """The display label published for a key (the explicit name= kwarg, else
420
+ the variable the bare form read, else the argument's source text), or
421
+ None. Labels ride the store object beside the values (the same
422
+ attach-to-object pattern as the watcher sets)."""
423
+ try:
424
+ obj = inspect.unwrap(obj)
425
+ except Exception:
426
+ pass
427
+ labels = getattr(obj, "__live_labels__", None)
428
+ return labels.get(key_path) if labels else None
429
+
430
+
431
+ def site_for_line(filename, lineno):
432
+ """(store_obj, key_path) of the live_view call at a 1-indexed absolute
433
+ file line — the EDITOR-side mirror of capture resolution. Same caches,
434
+ same span parse, same relativization, so the widget anchoring a value and
435
+ the publisher writing it converge on one key by construction. (None, None)
436
+ when no live_view call is surfaced at that line — e.g. while/with bodies,
437
+ whose line-keyed fallback sites have no token node to anchor anyway.
438
+
439
+ Memoized per (path, line) against _ast_for's (mtime, pending-gen)
440
+ signature: the editor overlay calls this for every visible live_view
441
+ token on every repaint, and the resolver chain behind it (realpath,
442
+ enclosing-def walk, span parse, linemap) is once-per-edit work, not
443
+ per-frame work."""
444
+ from meltygui.code.chain_converters import _enclosing_function
445
+ from meltygui.code.chain_converters import _module_for_file
446
+ try:
447
+ path = Path(filename).resolve()
448
+ mtime = path.stat().st_mtime
449
+ tree, text, sig = _ast_for(path, mtime)
450
+ _sck = (str(path), lineno)
451
+ _sch = _site_for_line_cache.get(_sck)
452
+ if _sch is not None and _sch[0] == sig:
453
+ return _sch[1]
454
+ # Same stamp to line bridge as _resolve_site, anchored at the
455
+ # enclosing def's disk start when one resolves (module-level stamps
456
+ # anchor at the stamp - no own-span growth to mis-count there).
457
+ _fn = _enclosing_function(str(path), lineno)
458
+ _fc = getattr(_fn, "__code__", None)
459
+ line_p = lineno + _stamp_delta(
460
+ path, _fc.co_firstlineno if _fc is not None else lineno)
461
+ span = _top_level_span(tree, line_p)
462
+ lm = _linemap_for(path, sig, span, text)
463
+ ref = _live_view_ref(lm, line_p)
464
+ if ref is None:
465
+ _site_for_line_cache[_sck] = (sig, (None, None))
466
+ return None, None
467
+ # Function-frame detection, editor flavor: capture reads CO_OPTIMIZED
468
+ # off the frame; here the parse path crossing a `<name>, "def"`
469
+ # segment says the call sits in a def body.
470
+ store_obj = None
471
+ store_is_module = True
472
+ if _owning_def_name(ref.path) is not None:
473
+ store_obj = _fn # resolved above for the line bridge
474
+ store_is_module = store_obj is None
475
+ if store_obj is None:
476
+ store_obj = _module_for_file(path)
477
+ if store_obj is None:
478
+ _site_for_line_cache[_sck] = (sig, (None, None))
479
+ return None, None
480
+ key_path = _store_relative(ref.path, store_obj, store_is_module)
481
+ _res = (store_obj, (key_path or (f"line:{lineno}",)))
482
+ _site_for_line_cache[_sck] = (sig, _res)
483
+ return _res
484
+ except Exception as e:
485
+ print(f"live_view: site_for_line failed for {filename}:{lineno}: "
486
+ f"{e!r}", file=sys.stderr)
487
+ return None, None
488
+
489
+
490
+ def install_builtin(name="live_view"):
491
+ """Make live_view() callable from ANY module with no import — the seamless
492
+ instrumentation path: type the call, hotswap, run. Mirrors Python's own
493
+ breakpoint(): a debugging entry point that shouldn't demand source changes
494
+ beyond the call itself. No-op if the name is already bound to something
495
+ else in builtins. Also taught to the static lint: code_checks freezes
496
+ dir(builtins) at ITS import, so patch its set when it loaded first."""
497
+ import builtins
498
+ existing = getattr(builtins, name, None)
499
+ if existing is not None and existing is not live_view:
500
+ return
501
+ setattr(builtins, name, live_view)
502
+ checks = sys.modules.get("meltygui.code.code_checks")
503
+ if checks is not None and hasattr(checks, "_BUILTIN_NAMES"):
504
+ checks._BUILTIN_NAMES = frozenset(checks._BUILTIN_NAMES) | {name}
505
+
506
+
507
+ def watch(store_obj, key_path, draw_state, first_only=False):
508
+ """Register a draw_state to invalidate when `key_path` publishes on
509
+ `store_obj`. Watchers ride the store object (attach-to-object, like the
510
+ values) in a per-key WeakSet — a closed window's draw_state just drops
511
+ out. Re-registering every render is the idempotent norm.
512
+
513
+ first_only=True fires ONLY on a key's FIRST value: the
514
+ marker dot registers this way, so a freshly-typed live_view() flips green
515
+ and auto-opens the moment its code first runs — one editor re-render per
516
+ new key — without paying a full editor recomposite on every publish.
517
+
518
+ key_path=None registers a STORE-LEVEL watcher, fired when ANY brand-new
519
+ key appears on the object. The snapshot overlay registers the editor this
520
+ way: before a function's first instrumented run there are no keys, hence
521
+ no markers, hence no per-key watchers — without this the first run's
522
+ values would sit invisible until an unrelated editor repaint."""
523
+ try:
524
+ if key_path is None:
525
+ vars(store_obj).setdefault(
526
+ "__live_store_watchers__", weakref.WeakSet()).add(draw_state)
527
+ return
528
+ attr = "__live_first_watchers__" if first_only else "__live_watchers__"
529
+ watchers = vars(store_obj).setdefault(attr, {})
530
+ watchers.setdefault(key_path, weakref.WeakSet()).add(draw_state)
531
+ except (AttributeError, TypeError):
532
+ pass
533
+
534
+
535
+ _PUBLISH_GEN = itertools.count(1)
536
+
537
+
538
+ def _stamp_publish_gen(value):
539
+ """Stamp a monotonic publish generation on a tensor-like value
540
+ (`_lv_pub`). Views that cache work per source (the voxel / line-graph
541
+ volume uploads) key on it next to `id()`/`_version`: a fresh run's
542
+ tensor is routinely allocated at the SAME id as the generation just
543
+ released at run start (`_release_store_generation` frees it first), and
544
+ `_version` starts at 0 for both — an identity-only key then hits the
545
+ cache and shows the previous run's texture. No invalidation involved:
546
+ the key changes exactly when a new value was published. Values that
547
+ refuse attributes (ndarrays, scalars) are left alone."""
548
+ try:
549
+ if hasattr(value, "shape") and hasattr(value, "__dict__"):
550
+ value._lv_pub = next(_PUBLISH_GEN)
551
+ except Exception:
552
+ pass
553
+
554
+
555
+ def publish_gen(value):
556
+ """The `_lv_pub` stamp of a published value (0 when unstamped) — fold
557
+ into any per-source cache key beside id()/_version."""
558
+ try:
559
+ return int(getattr(value, "_lv_pub", 0) or 0)
560
+ except Exception:
561
+ return 0
562
+
563
+
564
+ # Largest line distance a re-key may span: an edit shifts a site by the
565
+ # lines it inserted/deleted above it - a same-named assignment further away
566
+ # is a different site.
567
+ REKEY_MAX_SHIFT = 12
568
+
569
+
570
+ def bump_keys_gen(store_obj):
571
+ """Advance the store's KEY-SET generation (`__live_keys_gen__`) — called
572
+ wherever the set of store keys changes (first publish, re-key, prune).
573
+ The editor's view-name map is derived from the WHOLE key set (ordinal
574
+ disambiguation), so every consumer memoizes it on this generation and
575
+ they all agree within a frame — differently-stale maps minted the same
576
+ name for different keys (duplicate view IDs)."""
577
+ try:
578
+ d = vars(store_obj)
579
+ d["__live_keys_gen__"] = d.get("__live_keys_gen__", 0) + 1
580
+ except (AttributeError, TypeError):
581
+ pass
582
+
583
+
584
+ def _adopt_rekeyed(store_obj, key_path, store):
585
+ """If `key_path` is new to the store while an UNTOUCHED (this run)
586
+ same-label `line:N#name` key sits within REKEY_MAX_SHIFT lines, that
587
+ key is this site's previous generation: move its entry — value,
588
+ accumulator, label, dim names, both watcher sets, frame-snapshot
589
+ membership — under `key_path`. Nearest line wins; only during an active
590
+ run (a touched set exists) so a second same-named site is never
591
+ mistaken for a re-key outside the publish sequence."""
592
+ if key_path in store:
593
+ return
594
+ label = _key_label(key_path)
595
+ if label is None:
596
+ return
597
+ try:
598
+ d = vars(store_obj)
599
+ except TypeError:
600
+ return
601
+ touched = d.get("__live_touched__")
602
+ if touched is None:
603
+ return
604
+ line = _key_line(key_path)
605
+ best = None
606
+ for k in tuple(store):
607
+ if k in touched or _key_label(k) != label or len(k) != len(key_path):
608
+ continue
609
+ kl = _key_line(k)
610
+ if kl is None or line is None:
611
+ continue
612
+ dist = abs(kl - line)
613
+ if dist > REKEY_MAX_SHIFT or (best is not None and dist >= best[0]):
614
+ continue
615
+ best = (dist, k)
616
+ if best is None:
617
+ return
618
+ old = best[1]
619
+ for attr in ("__live_values__", "__live_accum__", "__live_labels__",
620
+ "__live_dim_names__", "__live_watchers__",
621
+ "__live_first_watchers__"):
622
+ m = d.get(attr)
623
+ if not m or old not in m:
624
+ continue
625
+ try:
626
+ ent = m.pop(old)
627
+ except (KeyError, RuntimeError):
628
+ continue
629
+ if key_path in m and attr in ("__live_watchers__",
630
+ "__live_first_watchers__"):
631
+ try:
632
+ m[key_path].update(ent)
633
+ except Exception:
634
+ m[key_path] = ent
635
+ else:
636
+ m[key_path] = ent
637
+ snaps = d.get("__frame_snapshot_keys__")
638
+ if snaps and old in snaps:
639
+ snaps.discard(old)
640
+ snaps.add(key_path)
641
+ bump_keys_gen(store_obj)
642
+
643
+
644
+ def _key_line(key_path):
645
+ """The line of a `line:N#name` key path's last segment, else None."""
646
+ tail = key_path[-1] if isinstance(key_path, tuple) and key_path else key_path
647
+ if not isinstance(tail, str) or not tail.startswith("line:"):
648
+ return None
649
+ num = tail[5:].partition("#")[0]
650
+ return int(num) if num.isdigit() else None
651
+
652
+
653
+ class RerunHint(str):
654
+ """The 'Rerun to visualize …' placeholder a released site holds. A str
655
+ subclass so every existing consumer (the value window's str view, tests)
656
+ keeps working, while views that treat plain strings specially can tell
657
+ it apart: the marker's INLINE label refuses it (it's an affordance, not
658
+ a captured value) and keeps the click/caret popover for it."""
659
+
660
+
661
+ def rerun_hint(store_obj, key_path):
662
+ """The placeholder a loop site holds while nothing shows it."""
663
+ label = (vars(store_obj).get("__live_labels__") or {}).get(key_path)
664
+ return RerunHint(f"Rerun to visualize {label or key_path[-1]}")
665
+
666
+
667
+ def park_rerun_hint(store_obj, key_path):
668
+ """A value window just stopped showing: forget its accumulated stack and
669
+ swap the store value for the rerun hint, then sever every watcher pin
670
+ (marker + window draw_states, GL texture) so the tensor has no referrer
671
+ left — the VRAM comes back now, not at the next run. The key STAYS (the
672
+ marker keeps reading captured); the next run with the window open
673
+ accumulates again. Any thread; never raises."""
674
+ try:
675
+ d = vars(store_obj)
676
+ accums = d.get("__live_accum__")
677
+ if accums:
678
+ accums.pop(key_path, None)
679
+ store = d.get("__live_values__")
680
+ if store is not None and key_path in store:
681
+ store[key_path] = rerun_hint(store_obj, key_path)
682
+ except (AttributeError, TypeError):
683
+ return
684
+ _release_key_watchers(store_obj, key_path)
685
+
686
+
687
+ # Headless override for the accumulate-only-when-watched gate below: tests
688
+ # (and any widgetless driver) flip this on so loop sites stack without a
689
+ # window watching them. Patch via unittest.mock / direct assignment.
690
+ ACCUMULATE_UNWATCHED = False
691
+
692
+
693
+ def has_visible_widget(store_obj, key_path):
694
+ """True when some live widget for `key_path` currently SHOWS its value
695
+ window. The full per-publish watcher (`__live_watchers__`) is registered
696
+ only while a marker's window exists, but draw_states persist past an
697
+ X-close, so also require the watcher's window ds to be open. Any
698
+ thread; never raises."""
699
+ if ACCUMULATE_UNWATCHED:
700
+ return True
701
+ try:
702
+ watchers = vars(store_obj).get("__live_watchers__")
703
+ if not watchers:
704
+ return False
705
+ try:
706
+ targets = tuple(watchers.get(key_path) or ())
707
+ except RuntimeError: # concurrent registration resized the set
708
+ return True # benign, err on the side of accumulating
709
+ for ds in targets:
710
+ win = getattr(ds, "_lv_window_ds", None)
711
+ if win is not None and not getattr(win, "closed", False):
712
+ return True
713
+ # Re-keyed site: an edit above the line moved `line:N#name` to
714
+ # `line:M#name`, and the run publishes the new key before its
715
+ # (name-keyed, still open) marker has re-rendered and re-watched
716
+ # it. Nothing is registered under the new key yet, but the window
717
+ # is right there: treat an open window under any same-named key
718
+ # as watching (two same-named sites may over-accumulate for one
719
+ # run; the data loss the other way was the real cost).
720
+ label = _key_label(key_path)
721
+ if label is not None:
722
+ for k, targets in tuple(watchers.items()):
723
+ if k is key_path or _key_label(k) != label:
724
+ continue
725
+ try:
726
+ targets = tuple(targets or ())
727
+ except RuntimeError:
728
+ return True
729
+ for ds in targets:
730
+ win = getattr(ds, "_lv_window_ds", None)
731
+ if win is not None and not getattr(win, "closed", False):
732
+ return True
733
+ except (AttributeError, TypeError):
734
+ pass
735
+ return False
736
+
737
+
738
+ def _key_label(key_path):
739
+ """The token name of a `line:N#name` key path (its last segment), else
740
+ None — the line-free identity a site keeps across re-keys."""
741
+ tail = key_path[-1] if isinstance(key_path, tuple) and key_path else key_path
742
+ if not isinstance(tail, str) or not tail.startswith("line:"):
743
+ return None
744
+ _, sep, name = tail.partition("#")
745
+ return name if sep else None
746
+
747
+
748
+ _last_wake = 0.0
749
+
750
+
751
+ def _notify_watchers(store_obj, key_path, first):
752
+ """Invalidate every draw_state watching this key (the terminal-reader
753
+ pattern: invalidate from the publishing thread, render loop repaints) and
754
+ wake the render loop at most ~30/s so a hot training loop can't spin it.
755
+ First publishes additionally fire the first-only watcher set."""
756
+ attrs = ("__live_watchers__", "__live_first_watchers__") if first else (
757
+ "__live_watchers__",)
758
+ notified = False
759
+ for attr in attrs:
760
+ watchers = getattr(store_obj, attr, None)
761
+ if not watchers:
762
+ continue
763
+ try:
764
+ targets = tuple(watchers.get(key_path) or ())
765
+ except RuntimeError: # concurrent registration resized the set
766
+ continue
767
+ for ds in targets:
768
+ try:
769
+ ds.invalidate()
770
+ notified = True
771
+ except Exception:
772
+ pass
773
+ # The open value WINDOW is a nested root - the marker's
774
+ # invalidate stops at its own tile + ancestors and never
775
+ # reaches the window tile, - a rerun's publish leaves the
776
+ # window blitting the stale value. Invalidate FORCE-down its
777
+ # subtree via the marker's window handle (the draw_function
778
+ # completion pattern - safe from the publishing thread).
779
+ win = getattr(ds, "_lv_window_ds", None)
780
+ if win is not None and not getattr(win, "closed", False):
781
+ try:
782
+ from meltygui.core.melty import Melty
783
+ Melty.cache.invalidate_up(win._tile_id, force=True,
784
+ max_depth=8)
785
+ notified = True
786
+ except Exception:
787
+ pass
788
+ if first:
789
+ # A brand-new key: wake the store-level watchers (usually editors)
790
+ # so the overlay re-runs and materializes this key's marker.
791
+ try:
792
+ store_targets = tuple(
793
+ getattr(store_obj, "__live_store_watchers__", None) or ())
794
+ except RuntimeError:
795
+ store_targets = ()
796
+ for ds in store_targets:
797
+ try:
798
+ ds.invalidate()
799
+ notified = True
800
+ except Exception:
801
+ pass
802
+ if not notified:
803
+ return
804
+ _wake_render(throttle=True)
805
+
806
+
807
+ _wake_timer = None
808
+
809
+
810
+ def _wake_render(throttle):
811
+ """Wake the render loop. throttle=True rate-limits to ~30/s — but with a
812
+ TRAILING edge: a wake that falls inside the window arms a one-shot
813
+ timer, so the LAST publish of a burst always gets painted. Without it a
814
+ run's final publish landed inside the window of the one before, the
815
+ invalidation sat pending with the loop asleep, and the window stayed on
816
+ whatever intermediate layer the previous frame had caught."""
817
+ global _last_wake, _wake_timer
818
+ now = time.time()
819
+ if throttle and now - _last_wake <= 0.033:
820
+ if _wake_timer is None:
821
+ t = threading.Timer(0.04, _wake_render, kwargs={"throttle": False})
822
+ t.daemon = True
823
+ _wake_timer = t
824
+ t.start()
825
+ return
826
+ _last_wake = now
827
+ _wake_timer = None
828
+ try:
829
+ from meltygui.core.windowing.glfw_utils import request_render
830
+ request_render()
831
+ except Exception:
832
+ pass # headless (tests) - nothing to wake
833
+
834
+
835
+ def settle_store(store_obj, keys):
836
+ """Run-end pass: re-invalidate every watcher of `keys` and wake the loop
837
+ UNTHROTTLED, so the state rendered after a run is the run's FINAL state —
838
+ intermediate frames during a long run are fine, landing on one is not."""
839
+ for key in tuple(keys or ()):
840
+ try:
841
+ _notify_watchers(store_obj, key, first=False)
842
+ except Exception:
843
+ pass
844
+ _wake_render(throttle=False)
845
+
846
+
847
+ # ── capture internals ─────────────────────────────────────────────────────────
848
+
849
+ def _site_for(code, lineno):
850
+ per_code = _sites.get(id(code))
851
+ if per_code is not None and lineno in per_code:
852
+ return per_code[lineno]
853
+ with _lock:
854
+ per_code = _sites.get(id(code))
855
+ if per_code is None:
856
+ per_code = {}
857
+ _sites[id(code)] = per_code
858
+ weakref.finalize(code, _sites.pop, id(code), None)
859
+ # Resolve under the lock; a racing duplicate resolution writes the same
860
+ # result twice.
861
+ if lineno not in per_code:
862
+ try:
863
+ site = _resolve_site(code, lineno)
864
+ except Exception as e:
865
+ print(f"live_view: site resolution failed for "
866
+ f"{code.co_filename}:{lineno}: {e!r}", file=sys.stderr)
867
+ site = None
868
+ per_code[lineno] = site
869
+ return per_code[lineno]
870
+
871
+
872
+ def _publish(site, value, name, bare, dims=None, idx=None):
873
+ try:
874
+ # setdefault on the object's __dict is atomic under the GIL, so two
875
+ # threads first-publishing to a function can't drop a store.
876
+ store = vars(site.store_obj).setdefault("__live_values__", {})
877
+ except (AttributeError, TypeError):
878
+ return
879
+ first = site.key_path not in store
880
+ # Register on EVERY publish, not just a key's first - the store rides
881
+ # the function object and outlives the session, so after a restart
882
+ # every key is already "known" and a first-only registration left the
883
+ # owner registry EMPTY - release_all_live_stores (cleanup, the OOM
884
+ # responder) then dropped nothing. WeakSet.add is O(1).
885
+ _register_store_owner(site.store_obj)
886
+ label = name or (site.var_name if bare else site.arg_label)
887
+ if label:
888
+ vars(site.store_obj).setdefault("__live_labels__", {})[
889
+ site.key_path] = label
890
+ # Re-keyed site (an edit above it moved `line:N#name` to `line:M#name`):
891
+ # rename the OLD key's entry in place before this first publish, so the
892
+ # two generations never coexist - the marker/window names (ordinal
893
+ # among same-label keys), the watcher sets, the accumulator and the
894
+ # value all carry over, the end-of-run pass sees nothing removed, and
895
+ # nothing needs invalidating beyond this key's own publish.
896
+ _adopt_rekeyed(site.store_obj, site.key_path, store)
897
+ # A loop site accumulates: the store holds the growing stack/list, the
898
+ # raw per-iteration value only feeds the type recorder below. Must run
899
+ # BEFORE the touched.add - "key not yet touched this run" is how the
900
+ # accumulator detects a fresh run and resets.
901
+ display = value
902
+ if dims and not has_visible_widget(site.store_obj, site.key_path):
903
+ # Loop site nobody is LOOKING at: don't stack (a 32-layer stack of
904
+ # hidden states is gigabytes PER key, repeated for every hidden
905
+ # widget). Drop any existing stack and park a hint in its place -
906
+ # opening the widget shows it, and the next run, with the window
907
+ # now watching, accumulates for real.
908
+ try:
909
+ vars(site.store_obj).get("__live_accum__", {}).pop(
910
+ site.key_path, None)
911
+ except Exception:
912
+ pass
913
+ display = rerun_hint(site.store_obj, site.key_path)
914
+ elif dims:
915
+ try:
916
+ display = _accumulate(site.store_obj, site.key_path, value,
917
+ tuple(dims), idx)
918
+ except Exception as e:
919
+ # Never break the publish; drop the entry so the next one restarts.
920
+ try:
921
+ vars(site.store_obj).get("__live_accum__", {}).pop(
922
+ site.key_path, None)
923
+ except Exception:
924
+ pass
925
+ print(f"live_view: accumulate failed for {site.key_path}: {e!r}",
926
+ file=sys.stderr)
927
+ display = value
928
+ touched = vars(site.store_obj).get("__live_touched__")
929
+ if touched is not None and site.key_path not in touched and not first:
930
+ # FIRST publish of this key in a fresh run: the store is about to
931
+ # drop the previous generation of value - make sure it actually
932
+ # dies. The window's marker / draw_states pin the value they just
933
+ # rendered (and the window's GL volume / cuda_view cache pins it
934
+ # too) until they re-render, which doesn't happen until the run is
935
+ # done publishing - so without this every visible view kept the OLD
936
+ # generation alive for the whole run and peak VRAM was 2× (views
937
+ # hidden → 1×, the tell). Release now; the window refills from the
938
+ # store on its next frame (the publish below notifies it).
939
+ _release_key_watchers(site.store_obj, site.key_path)
940
+ # The value is stored RAW - a single object assignment, atomic under the
941
+ # GIL, so the rendering thread always reads either the old or new value.
942
+ _stamp_publish_gen(display)
943
+ store[site.key_path] = display
944
+ if first:
945
+ bump_keys_gen(site.store_obj) # key set grew - view names re-rank
946
+ # Publish ORDER, one int per key: the inline USAGE labels resolve a
947
+ # usage to the binding above it that actually published LAST (the
948
+ # branch-not-taken rule - see live_usage.governing_key). Monotonic
949
+ # across the process via the shared generation counter.
950
+ try:
951
+ _sv = vars(site.store_obj)
952
+ _sv.setdefault("__live_pub_seq__", {})[
953
+ site.key_path] = next(_PUBLISH_GEN)
954
+ # Store-level publish generation: the usage marker memoizes each
955
+ # occurrence's governing key against it (live_view_views).
956
+ _sv["__live_pub_gen__"] = _sv.get("__live_pub_gen__", 0) + 1
957
+ except (AttributeError, TypeError):
958
+ pass
959
+ # Run-scope liveness: while a run_capture is active for this store,
960
+ # every published key is recorded so the run's exit can prune the rest
961
+ # (set.add - atomic under the GIL).
962
+ if touched is not None:
963
+ touched.add(site.key_path)
964
+ _record_scope_type(site, value, name, bare)
965
+ _notify_watchers(site.store_obj, site.key_path, first=first)
966
+
967
+
968
+ # Sliding-window cap for a loop-site publishing outside any run_capture
969
+ # scope (a free-running training loop has no run boundary to stop at).
970
+ _ACCUM_CAP = 512
971
+
972
+
973
+ def _accumulate(store_obj, key_path, value, dims, idx):
974
+ """Fold one LOOP-SITE publish into its per-key accumulator and return the
975
+ DISPLAY value the store should hold: tensors/ndarrays append along a new
976
+ leading dim (a doubling growth buffer — amortized O(1) per publish, no
977
+ per-iteration restack), everything else appends to a list.
978
+
979
+ `dims` is the static tuple of enclosing-loop names, outermost first
980
+ (`for l_idx, layer in enumerate(…)` → 'l_idx'; while → 'iter'); `idx` is
981
+ those loop variables' CURRENT values read from the publishing frame.
982
+ An all-int idx keys the entry by ITERATION INDEX, so a repeated pass
983
+ (an outer epoch loop, a re-called function) OVERWRITES in place instead
984
+ of growing — natural rollover — and a complete rectangular nested grid
985
+ reshapes into one named dim PER loop. Non-int idx (`for layer in
986
+ layers`, while bodies) appends flat in publish order; nested loops that
987
+ can't grid flatten under a composite ' × '-joined name.
988
+
989
+ The auto dim names land in __live_dim_names__[key_path] (read by the
990
+ editor marker, which prepends them to the site's user dim_names before
991
+ the value-window call). A run_capture scope resets a key's accumulator
992
+ on its first publish of the run — the store keeps showing the finished
993
+ stack between runs. Tensors are detached first: a graph-carrying stack
994
+ would pin autograd memory across the whole loop."""
995
+ try:
996
+ accums = vars(store_obj).setdefault("__live_accum__", {})
997
+ except (AttributeError, TypeError):
998
+ return value
999
+ kind = type(value).__name__
1000
+ stackable = (kind == "Tensor"
1001
+ or (kind == "ndarray" and value.dtype.kind in "fiub"))
1002
+ if kind == "Tensor":
1003
+ try:
1004
+ value = value.detach()
1005
+ except Exception:
1006
+ pass
1007
+ touched = vars(store_obj).get("__live_touched__")
1008
+ ent = accums.get(key_path)
1009
+ if (ent is None or ent["dims"] != dims
1010
+ or (touched is not None and key_path not in touched)):
1011
+ # A fresh run's stack almost always ends the same size as the last
1012
+ # one (same loop) - size the buffer to that at the start instead
1013
+ # of doubling up to it: each doubling step held the old + new
1014
+ # buffer (the 16→32 step on a 32-layer (32, 1413, 1413) stack is
1015
+ # a 2 GB + 4 GB transient per key) - on a card that's full, the
1016
+ # transient IS the OOM.
1017
+ hint = 0
1018
+ if ent is not None and ent["dims"] == dims:
1019
+ hint = int(ent["n"] or ent.get("hint", 0))
1020
+ ent = accums[key_path] = {
1021
+ "dims": dims, # static loop names, outermost first
1022
+ "slots": {}, # idx tuple | ('#', n) counter -> row/seq position
1023
+ "buf": None, # stack growth buffer (cap, *shape) | None
1024
+ "n": 0, # buffer rows in use
1025
+ "seq": None, # list fallback (non-stackable / ragged shapes)
1026
+ "hint": hint, # last run's final row count - the initial cap
1027
+ }
1028
+ by_index = (idx is not None and len(idx) == len(dims)
1029
+ and all(type(i) is int for i in idx))
1030
+ slot_key = tuple(idx) if by_index else ("#", len(ent["slots"]))
1031
+
1032
+ buf = ent["buf"]
1033
+ if buf is not None and (not stackable
1034
+ or (kind == "Tensor") != (type(buf).__name__
1035
+ == "Tensor")
1036
+ or tuple(buf.shape[1:]) != tuple(value.shape)):
1037
+ # Genuinely ragged (not the value's shape): give up the packed
1038
+ # buffer, keep the rows (views into it) and continue as a plain
1039
+ # list. Dtype/device drift is NOT a deopt - copy_ casts and crosses
1040
+ # devices below, so a sharded/offloaded model's per-layer values
1041
+ # still stack into the first layer's buffer.
1042
+ print(f"live_view: {key_path} accumulates as a list — "
1043
+ f"{getattr(value, 'shape', type(value).__name__)} doesn't "
1044
+ f"stack on buffer {tuple(buf.shape[1:])}", file=sys.stderr)
1045
+ ent["seq"] = [buf[i] for i in range(ent["n"])]
1046
+ ent["buf"] = buf = None
1047
+
1048
+ if not stackable or ent["seq"] is not None:
1049
+ seq = ent["seq"]
1050
+ if seq is None:
1051
+ seq = ent["seq"] = []
1052
+ pos = ent["slots"].get(slot_key)
1053
+ if pos is not None and pos < len(seq):
1054
+ seq[pos] = value
1055
+ else:
1056
+ ent["slots"][slot_key] = len(seq)
1057
+ seq.append(value)
1058
+ if len(seq) > _ACCUM_CAP:
1059
+ del seq[0]
1060
+ ent["slots"] = {("#", i): i for i in range(len(seq))}
1061
+ _stamp_auto_dims(store_obj, key_path, dims, nested=False)
1062
+ return seq
1063
+
1064
+ if buf is None:
1065
+ buf = ent["buf"] = _accum_alloc(
1066
+ value, max(4, min(_ACCUM_CAP, int(ent.get("hint", 0)))))
1067
+ pos = ent["slots"].get(slot_key)
1068
+ if pos is None:
1069
+ if ent["n"] >= _ACCUM_CAP:
1070
+ # Sliding window: pop off the oldest row. Index identity is
1071
+ # gone after a window, so slots degrade to flat counters.
1072
+ src = buf[1:ent["n"]]
1073
+ buf[:ent["n"] - 1] = (src.clone() if kind == "Tensor"
1074
+ else src.copy())
1075
+ pos = ent["n"] - 1
1076
+ ent["slots"] = {("#", i): i for i in range(pos + 1)}
1077
+ else:
1078
+ if ent["n"] >= buf.shape[0]:
1079
+ # Grow from the BUFFER's dtype/device (not the value's - a
1080
+ # drifting value must not silently re-home the whole stack).
1081
+ cap = min(_ACCUM_CAP, max(4, buf.shape[0] * 2))
1082
+ if kind == "Tensor":
1083
+ new = buf.new_empty((cap,) + tuple(buf.shape[1:]))
1084
+ else:
1085
+ import numpy as np
1086
+ new = np.empty((cap,) + tuple(buf.shape[1:]),
1087
+ dtype=buf.dtype)
1088
+ new[:ent["n"]] = buf[:ent["n"]]
1089
+ buf = ent["buf"] = new
1090
+ pos = ent["n"]
1091
+ ent["n"] += 1
1092
+ ent["slots"][slot_key] = pos
1093
+ if kind == "Tensor":
1094
+ # copy_ casts dtype and crosses devices - mixed-precision layers and
1095
+ # device_map-sharded models stack instead of deopting to a list.
1096
+ buf[pos].copy_(value)
1097
+ else:
1098
+ buf[pos] = value
1099
+
1100
+ flat = buf[:ent["n"]]
1101
+ nested = False
1102
+ if len(dims) > 1 and ent["n"]:
1103
+ ks = list(ent["slots"])
1104
+ if all(k[0] != "#" for k in ks) and ks == sorted(ks):
1105
+ extents = tuple(len({k[d] for k in ks})
1106
+ for d in range(len(dims)))
1107
+ total = 1
1108
+ for e in extents:
1109
+ total *= e
1110
+ if total == ent["n"]:
1111
+ flat = flat.reshape(extents + tuple(buf.shape[1:]))
1112
+ nested = True
1113
+ _stamp_auto_dims(store_obj, key_path, dims, nested)
1114
+ return flat
1115
+
1116
+
1117
+ def _accum_alloc(value, cap):
1118
+ """An uninitialized (cap, *value.shape) buffer matching value's dtype
1119
+ (and device, for torch tensors)."""
1120
+ if type(value).__name__ == "Tensor":
1121
+ return value.new_empty((cap,) + tuple(value.shape))
1122
+ import numpy as np
1123
+ return np.empty((cap,) + tuple(value.shape), dtype=value.dtype)
1124
+
1125
+
1126
+ def _stamp_auto_dims(store_obj, key_path, dims, nested):
1127
+ """Record the accumulated dims' auto names for the editor marker: one
1128
+ name per loop when the stack is nested (or single-loop), a composite
1129
+ ' × ' name for a flattened multi-loop stack."""
1130
+ names = tuple(dims) if (nested or len(dims) == 1) else (" × ".join(dims),)
1131
+ try:
1132
+ stamped = vars(store_obj).setdefault("__live_dim_names__", {})
1133
+ stamped[key_path] = names
1134
+ except (AttributeError, TypeError):
1135
+ pass
1136
+
1137
+
1138
+ def auto_dim_names_for(obj, key_path):
1139
+ """The auto loop-dim names a key's accumulated value carries (leading
1140
+ dims, outermost first), or None. Editor-side reader — the marker
1141
+ prepends these to the site's user dim_names before the value-window
1142
+ call."""
1143
+ try:
1144
+ obj = inspect.unwrap(obj)
1145
+ except Exception:
1146
+ pass
1147
+ names = getattr(obj, "__live_dim_names__", None)
1148
+ return names.get(key_path) if names else None
1149
+
1150
+
1151
+ def _record_scope_type(site, value, name, bare):
1152
+ """Feed the published value's runtime type into FuncsMetadata, keyed by the
1153
+ owning function — so an editor on that function's source autocompletes
1154
+ `x.` against the LIVE type of x, for every local a live_view / snapshot
1155
+ run has seen. This is the mid-body complement to the context menu's stack
1156
+ capture (which only sees callers' frames and the target's entry kwargs).
1157
+
1158
+ The recorded NAME must be a real local: the bare form's resolved
1159
+ assignment target, or an explicit call's arg source when it is a plain
1160
+ identifier (`live_view(attn_weights, ...)`); expression args (`x[0].w`)
1161
+ and display-only `name=` labels are skipped. One exception: a snapshot
1162
+ stamp (instrumented_twin's injected `__lv_view__(x, name='x')`) has NO
1163
+ call in the SOURCE at its line, so the site carries no arg_label — there
1164
+ the injected `name` IS the assignment target by construction, and only
1165
+ then is it trusted as the local's name. Module/class-body stores are
1166
+ skipped too — module-level names already complete via the live namespace
1167
+ walk. Cheap on hot publish paths: record_value no-ops on an unchanged
1168
+ type. Best-effort; a hiccup must never break a publish."""
1169
+ store_obj = site.store_obj
1170
+ if not isinstance(store_obj, types.FunctionType):
1171
+ return
1172
+ local_name = site.var_name if bare else site.arg_label
1173
+ if not (isinstance(local_name, str) and local_name.isidentifier()):
1174
+ if not (site.arg_label is None and not bare and isinstance(name, str)):
1175
+ return
1176
+ local_name = name
1177
+ if not local_name.isidentifier():
1178
+ return
1179
+ try:
1180
+ from meltygui.core.rendering.func_metadata import FuncsMetadata
1181
+ FuncsMetadata.record_value(store_obj, local_name, value)
1182
+ except Exception:
1183
+ pass
1184
+
1185
+
1186
+ # The single owner of the run_capture currently open on THIS thread (a small
1187
+ # stack: nested captures are possible in principle). twin_snap publishes to
1188
+ # it instead of re-resolving the store by (file, line): the resolver walk can
1189
+ # find a DIFFERENT function object than the one being run (the module's real
1190
+ # function vs the def-run path's exec'd twin parked beside it - same
1191
+ # co_firstlineno, first-in-dict wins), and the run_capture prunes one store
1192
+ # while the run fills another: stale keys (every renamed/removed site,
1193
+ # each with its accumulated stack) were never swept, and the accumulator
1194
+ # never saw a fresh run - the "accumulators still growing" symptom.
1195
+ _run_owner = threading.local()
1196
+
1197
+
1198
+ def current_run_owner():
1199
+ stack = getattr(_run_owner, "stack", None)
1200
+ return stack[-1] if stack else None
1201
+
1202
+
1203
+ def _register_store_owner(store_obj):
1204
+ try:
1205
+ _store_owners.add(store_obj)
1206
+ except TypeError:
1207
+ pass # not weak-referenceable - won't be swept, fine
1208
+
1209
+
1210
+ def _discover_store_owners():
1211
+ """Every function object carrying a live store, found by a gc walk —
1212
+ the fallback for a registry that missed owners. A module scan is NOT
1213
+ enough: the store rides the function OBJECT, and a recompile/hotswap
1214
+ replaces the module's attribute while the superseded function lives on
1215
+ (the `_sites` cache holds it via Site.store_obj) with its whole store —
1216
+ 14 GB of stacks in one session, reachable from nothing nameable.
1217
+ Unfreezes first: gc_manager freezes the boot generation and
1218
+ gc.get_objects() skips frozen objects. OOM-time only (opt-in via
1219
+ release_all_live_stores(discover=True)): the walk is O(the process
1220
+ heap) — ~0.5 s for one session, but the model_server process keeps
1221
+ prior sessions' graphs, so it grows with every launch. Never run it
1222
+ on the session-teardown path."""
1223
+ import gc
1224
+ try:
1225
+ gc.unfreeze()
1226
+ except Exception:
1227
+ pass
1228
+ found = []
1229
+ for o in gc.get_objects():
1230
+ if not isinstance(o, types.FunctionType):
1231
+ continue
1232
+ try:
1233
+ d = vars(o)
1234
+ except TypeError:
1235
+ continue
1236
+ if "__live_values__" in d or "__live_accum__" in d:
1237
+ found.append(o)
1238
+ return found
1239
+
1240
+
1241
+ def release_all_live_stores(discover=False):
1242
+ """Drop EVERY live-view store: values, loop accumulators (the per-run
1243
+ stacks — gigabytes), labels, watchers; close/release every marker and
1244
+ value window that watched them (GL textures included, via
1245
+ release_live_value). The CUDA-OOM response: after an out-of-memory the
1246
+ live set IS the VRAM, and a partial run's stacks + every window's pinned
1247
+ generation must go before anything can run again. Markers re-publish
1248
+ and windows re-fill on the next run; nothing is lost that a run doesn't
1249
+ recreate. Returns the number of keys dropped. Safe from any thread.
1250
+
1251
+ Owners come from the `_store_owners` registry, which is complete for
1252
+ this module instance: `_publish` registers on EVERY publish, and
1253
+ `run_capture` / `adopt_live_store` register too. `discover=True` adds
1254
+ the `_discover_store_owners` gc walk as a belt-and-braces fallback —
1255
+ O(the whole process heap), and the model_server process carries prior
1256
+ sessions' graphs (see gc_manager._boot_collect_and_freeze), so at
1257
+ session teardown that walk grew with every launch: the multi-second
1258
+ hang after "Cleaning CUDA context". Melty.cleanup therefore uses the
1259
+ registry alone; the OOM responder, a rare recovery where a missed
1260
+ 14 GB store costs more than the walk, still discovers."""
1261
+ dropped = 0
1262
+ owners = list(_store_owners)
1263
+ if discover:
1264
+ known = {id(o) for o in owners}
1265
+ for o in _discover_store_owners():
1266
+ if id(o) not in known:
1267
+ owners.append(o)
1268
+ _register_store_owner(o)
1269
+ for owner in owners:
1270
+ try:
1271
+ store = vars(owner).get("__live_values__")
1272
+ except TypeError:
1273
+ continue
1274
+ keys = list(store) if store else []
1275
+ if keys:
1276
+ _prune_keys(owner, keys)
1277
+ dropped += len(keys)
1278
+ # Anything _prune_keys leaves behind (a key that never published a
1279
+ # value but has an accumulator entry, a fresh-run scope, ...).
1280
+ for attr in ("__live_accum__", "__live_dim_names__", "__live_touched__"):
1281
+ try:
1282
+ d = vars(owner).get(attr)
1283
+ if hasattr(d, "clear"):
1284
+ d.clear()
1285
+ except TypeError:
1286
+ pass
1287
+ return dropped
1288
+
1289
+
1290
+ @contextmanager
1291
+ def run_capture(store_obj):
1292
+ """Scope one instrumented run over `store_obj` (pass the UNWRAPPED
1293
+ function — the object capture attaches to). Keys published inside the
1294
+ with-block are recorded, and a SUCCESSFUL exit prunes every other key:
1295
+ an instrumented run republishes every assignment it still contains, so
1296
+ anything not touched is a REMOVED line — without this, stale keys linger
1297
+ forever as ghost markers, orphaned value windows, and line:N entries
1298
+ that jumble future resolution. An exception skips the prune: a partial
1299
+ run proves nothing about which sites still exist."""
1300
+ try:
1301
+ vars(store_obj)["__live_touched__"] = set()
1302
+ except (AttributeError, TypeError):
1303
+ yield
1304
+ return
1305
+ _register_store_owner(store_obj)
1306
+ # The previous generation goes BEFORE the run allocates the next generation -
1307
+ # every big value in the store (and the watchers' pins to it), not just
1308
+ # the keys this run fails to republish: an edit that removes a line
1309
+ # re-keys every `line:N#...` site below it, so the per-key release in
1310
+ # _publish never fires for them: the old stacks would live until the
1311
+ # end-of-run prune (or past it, if the run OOMs - a failed run prunes
1312
+ # nothing). Windows hold their last frame anyway; small values
1313
+ # (scalars, strings) stay so text markers don't blink.
1314
+ _release_store_generation(store_obj)
1315
+ stack = getattr(_run_owner, "stack", None)
1316
+ if stack is None:
1317
+ stack = _run_owner.stack = []
1318
+ stack.append(store_obj)
1319
+ try:
1320
+ yield
1321
+ except BaseException:
1322
+ touched = vars(store_obj).pop("__live_touched__", None)
1323
+ # A failed run (OOM mid-loop) still re-paints what it did publish
1324
+ # - the windows must show the store's truth, not a stale frame.
1325
+ settle_store(store_obj, touched)
1326
+ raise
1327
+ finally:
1328
+ if stack and stack[-1] is store_obj:
1329
+ stack.pop()
1330
+ touched = vars(store_obj).pop("__live_touched__", set())
1331
+ _prune_untouched(store_obj, touched)
1332
+ settle_store(store_obj, touched)
1333
+
1334
+
1335
+ _RELEASE_MIN_BYTES = 1 << 20
1336
+
1337
+
1338
+ def _big_tensorish(v, min_bytes=_RELEASE_MIN_BYTES):
1339
+ kind = type(v).__name__
1340
+ try:
1341
+ if kind == "Tensor":
1342
+ return v.numel() * v.element_size() >= min_bytes
1343
+ if kind == "ndarray":
1344
+ return v.nbytes >= min_bytes
1345
+ except Exception:
1346
+ return False
1347
+ return False
1348
+
1349
+
1350
+ def _release_store_generation(store_obj, min_bytes=_RELEASE_MIN_BYTES):
1351
+ """Drop every big tensor/ndarray value in `store_obj`'s store (the entry
1352
+ stays, value None — the marker still reads as captured and its window
1353
+ holds its last frame), clear the matching accumulator buffers (keeping
1354
+ the row count as the next run's pre-size hint), and release the
1355
+ watchers' pins. Returns the number of values released. Any thread."""
1356
+ try:
1357
+ store = vars(store_obj).get("__live_values__")
1358
+ accums = vars(store_obj).get("__live_accum__") or {}
1359
+ except TypeError:
1360
+ return 0
1361
+ if not store:
1362
+ return 0
1363
+ n = 0
1364
+ for key in list(store):
1365
+ v = store.get(key)
1366
+ if not _big_tensorish(v, min_bytes):
1367
+ continue
1368
+ store[key] = None
1369
+ ent = accums.get(key)
1370
+ if ent is not None:
1371
+ ent["hint"] = int(ent.get("n") or ent.get("hint", 0))
1372
+ ent["buf"] = None
1373
+ ent["seq"] = None
1374
+ ent["slots"] = {}
1375
+ ent["n"] = 0
1376
+ _release_key_watchers(store_obj, key)
1377
+ n += 1
1378
+ return n
1379
+
1380
+
1381
+ def _release_key_watchers(store_obj, key_path):
1382
+ """Drop the captured-value refs (+ GL resources) held by every marker and
1383
+ value window watching `key_path` — WITHOUT closing them or touching the
1384
+ store. The per-run peak-VRAM guard (see _publish); same release as a
1385
+ prune, minus the prune. Any thread."""
1386
+ try:
1387
+ from meltygui.editor.live_view_views import release_live_value
1388
+ except Exception:
1389
+ return
1390
+ for attr in ("__live_watchers__", "__live_first_watchers__"):
1391
+ watchers = getattr(store_obj, attr, None)
1392
+ if not watchers:
1393
+ continue
1394
+ try:
1395
+ targets = tuple(watchers.get(key_path) or ())
1396
+ except RuntimeError:
1397
+ targets = ()
1398
+ for ds in targets:
1399
+ try:
1400
+ release_live_value(ds, gl=False)
1401
+ win = getattr(ds, "_lv_window_ds", None)
1402
+ if win is not None:
1403
+ # keep the FBO's last image: the window shows its last
1404
+ # frame until the fresh value lands (no error flash)
1405
+ release_live_value(win, keep_image=True)
1406
+ except Exception:
1407
+ pass
1408
+
1409
+
1410
+ def _retained_markers(store_obj, store, removed):
1411
+ """ids of watcher draw_states (markers) of `removed` keys that must keep
1412
+ their window: the marker still serves a SURVIVING key. Two tests, either
1413
+ suffices:
1414
+ - identity: the same ds is registered under a surviving key (the
1415
+ marker rendered since the re-key and re-watched);
1416
+ - stable name: the removed key's line-free name (label + ordinal
1417
+ among same-label keys, the convention marker/window names already
1418
+ use — live_view_views._stable_key_names) is also the name of a
1419
+ surviving key. Old-generation names rank over removed ∪ survivors
1420
+ that already have watchers (keys published before this run);
1421
+ new-generation names over the survivors — so an in-def shift of
1422
+ every `hidden` site keeps every `hidden` window.
1423
+ Markers are name-keyed (`lvs::fn::hidden#1`), so a site that moved
1424
+ lines comes back as the SAME draw_state — nothing to hand over."""
1425
+ keep = set()
1426
+ removed_set = set(removed)
1427
+ survivors = [k for k in store if k not in removed_set]
1428
+ if not survivors:
1429
+ return keep
1430
+ watcher_maps = []
1431
+ for attr in ("__live_watchers__", "__live_first_watchers__"):
1432
+ w = getattr(store_obj, attr, None)
1433
+ if w:
1434
+ watcher_maps.append(w)
1435
+
1436
+ def _targets(k):
1437
+ out = []
1438
+ for w in watcher_maps:
1439
+ try:
1440
+ out.extend(tuple(w.get(k) or ()))
1441
+ except RuntimeError:
1442
+ pass
1443
+ return out
1444
+
1445
+ surviving_ds = set()
1446
+ old_gen = list(removed_set)
1447
+ for k in survivors:
1448
+ t = _targets(k)
1449
+ if t:
1450
+ surviving_ds.update(id(d) for d in t)
1451
+ old_gen.append(k)
1452
+ try:
1453
+ from meltygui.editor.live_view_views import _stable_key_names
1454
+ old_names = _stable_key_names(old_gen)
1455
+ new_names = set(_stable_key_names(survivors).values())
1456
+ except Exception:
1457
+ old_names, new_names = {}, set()
1458
+ for k in removed:
1459
+ for d in _targets(k):
1460
+ if id(d) in surviving_ds or old_names.get(k) in new_names:
1461
+ keep.add(id(d))
1462
+ return keep
1463
+
1464
+
1465
+ def _prune_untouched(store_obj, touched):
1466
+ """Drop every store key not in `touched` — run_capture's whole-store sweep.
1467
+ The per-key removal mechanics live in _prune_keys (shared with the frame-
1468
+ snapshot publisher, which prunes only ITS OWN stale keys)."""
1469
+ store = getattr(store_obj, "__live_values__", None)
1470
+ if not store:
1471
+ return
1472
+ _prune_keys(store_obj, [k for k in tuple(store) if k not in touched])
1473
+
1474
+
1475
+ def _prune_keys(store_obj, removed):
1476
+ """Remove the given store keys: value, label, per-key watcher sets, and
1477
+ any open value window (win.closed = True — the next root_draw_states
1478
+ dispatch discards it; if the key ever republishes, the marker re-registers
1479
+ its window with closed= driven fresh). Store-level watchers (the snapshot
1480
+ editors) are invalidated so the overlay re-runs without the removed
1481
+ markers. Safe from any thread — dict pops/copies are GIL-atomic and
1482
+ ds.invalidate() is the established cross-thread completion pattern."""
1483
+ store = getattr(store_obj, "__live_values__", None)
1484
+ if not store or not removed:
1485
+ return
1486
+ labels = getattr(store_obj, "__live_labels__", None)
1487
+ # Line shifts: keys are line-anchored (`line:N#name`), so an edit that
1488
+ # inserts/removes a line above a site re-keys it - the old key lands
1489
+ # here as "removed" while the SAME assignment republished under
1490
+ # `line:M#name`, served by the SAME name-keyed marker. Its window must
1491
+ # survive (see _retained_markers); only markers whose site is really
1492
+ # gone are closed.
1493
+ keep = _retained_markers(store_obj, store, removed)
1494
+ # One-time cleanup of the (temporary) parking lot: windows parked
1495
+ # under __live_window_handoff__ were never adopted and sat orphaned.
1496
+ try:
1497
+ for _w in (vars(store_obj).pop("__live_window_handoff__", None)
1498
+ or {}).values():
1499
+ _w.closed = True
1500
+ except Exception:
1501
+ pass
1502
+ for key in removed:
1503
+ store.pop(key, None)
1504
+ if labels:
1505
+ labels.pop(key, None)
1506
+ for _attr in ("__live_accum__", "__live_dim_names__"):
1507
+ _d = getattr(store_obj, _attr, None)
1508
+ if _d:
1509
+ _d.pop(key, None)
1510
+ for attr in ("__live_watchers__", "__live_first_watchers__"):
1511
+ watchers = getattr(store_obj, attr, None)
1512
+ if not watchers:
1513
+ continue
1514
+ try:
1515
+ targets = tuple(watchers.pop(key, None) or ())
1516
+ except RuntimeError:
1517
+ targets = ()
1518
+ for ds in targets:
1519
+ if id(ds) in keep:
1520
+ # The same marker (name-stable: line stripped, ordinal
1521
+ # among same-label keys) still serves a surviving key -
1522
+ # the site moved lines, it didn't go away. Its window
1523
+ # stays open and pinned; the marker re-watches the new
1524
+ # key on its next render (an open window keeps it out
1525
+ # of _marker_idle_skip).
1526
+ continue
1527
+ win = getattr(ds, "_lv_window_ds", None)
1528
+ if win is not None:
1529
+ try:
1530
+ win.closed = True
1531
+ except Exception:
1532
+ pass
1533
+ try:
1534
+ ds._lv_open = False
1535
+ ds.invalidate()
1536
+ except Exception:
1537
+ pass
1538
+ # The marker and its window stop rendering for good (the key
1539
+ # is gone) but their draw_states persist: drop the captured
1540
+ # value they hold (and the window's GPU texture), or every
1541
+ # pruned key leaves a generation of tensors for the session.
1542
+ try:
1543
+ from meltygui.editor.live_view_views import release_live_value
1544
+ release_live_value(ds, gl=False)
1545
+ if win is not None:
1546
+ release_live_value(win)
1547
+ except Exception:
1548
+ pass
1549
+ bump_keys_gen(store_obj) # key set shrank - view names re-rank
1550
+ try:
1551
+ store_targets = tuple(
1552
+ getattr(store_obj, "__live_store_watchers__", None) or ())
1553
+ except RuntimeError:
1554
+ store_targets = ()
1555
+ for ds in store_targets:
1556
+ try:
1557
+ ds.invalidate()
1558
+ except Exception:
1559
+ pass
1560
+ try:
1561
+ from meltygui.core.windowing.glfw_utils import request_render
1562
+ request_render()
1563
+ except Exception:
1564
+ pass # headless (test)
1565
+
1566
+
1567
+ _STORE_ATTRS = ("__live_values__", "__live_accum__", "__live_labels__",
1568
+ "__live_dim_names__", "__live_watchers__",
1569
+ "__live_first_watchers__", "__live_store_watchers__",
1570
+ "__frame_snapshot_keys__",
1571
+ "__live_return_line__", "__live_error_line__")
1572
+ # (__live_touched__ stays put: it is an in-flight run_capture's marker on the
1573
+ # OLD function - moving it would just make that run prune an empty store.)
1574
+
1575
+
1576
+ def adopt_live_store(old, new):
1577
+ """Move the live-view store from one function object to its SUCCESSOR —
1578
+ the def-run path (text_editor._fnrun_resolve) exec-compiles a FRESH
1579
+ function per body edit, and the old one — unreachable from any module
1580
+ var once re-parked — still owned its whole `__live_values__` /
1581
+ `__live_accum__` (the per-layer stacks: gigabytes per run). One owner
1582
+ per def: `new` takes over the dicts BY IDENTITY (watchers, markers and
1583
+ open value windows keep working unchanged — publishes to `new` land in
1584
+ the same dicts), and `old` drops them, so a superseded function pins
1585
+ nothing. If `new` already has a store (the real module function that ran
1586
+ before its exec twin did) its own wins and the old one is just dropped —
1587
+ the memory, not the continuity, is what matters."""
1588
+ if old is None or new is None or old is new:
1589
+ return
1590
+ try:
1591
+ od, nd = vars(old), vars(new)
1592
+ except TypeError:
1593
+ return
1594
+ for attr in _STORE_ATTRS:
1595
+ if attr not in od:
1596
+ continue
1597
+ moved = od.pop(attr)
1598
+ if attr not in nd:
1599
+ nd[attr] = moved
1600
+ _register_store_owner(new)
1601
+
1602
+
1603
+ def clear_file_stores(filename):
1604
+ """Drop EVERY captured live_view value riding this file's stores: the
1605
+ module object plus each function/method whose code lives in the file
1606
+ (unwrapped — capture attaches to the inner function). Goes through
1607
+ _prune_keys, so open value windows close, marker dots flip back to gray
1608
+ and store-level watchers repaint; per-run markers (return/error line
1609
+ washes) are dropped too. The editor's live-badge × calls this. Returns
1610
+ the number of values dropped."""
1611
+ from meltygui.code.chain_converters import _module_for_file
1612
+ try:
1613
+ path = Path(filename).resolve()
1614
+ except Exception:
1615
+ return 0
1616
+ mod = _module_for_file(path)
1617
+ if mod is None:
1618
+ return 0
1619
+ fname = str(path)
1620
+ objs, seen = [mod], {id(mod)}
1621
+
1622
+ def _collect(ns):
1623
+ for v in list(ns.values()):
1624
+ try:
1625
+ v = inspect.unwrap(v)
1626
+ except Exception:
1627
+ pass
1628
+ if id(v) in seen:
1629
+ continue
1630
+ code = getattr(v, "__code__", None)
1631
+ if code is not None and getattr(code, "co_filename", None) == fname:
1632
+ seen.add(id(v))
1633
+ objs.append(v)
1634
+ elif (isinstance(v, type)
1635
+ and getattr(v, "__module__", None) == getattr(mod, "__name__", None)):
1636
+ seen.add(id(v))
1637
+ _collect(dict(vars(v)))
1638
+
1639
+ _collect(dict(vars(mod)))
1640
+ dropped = 0
1641
+ for obj in objs:
1642
+ store = getattr(obj, "__live_values__", None)
1643
+ if store:
1644
+ keys = tuple(store)
1645
+ dropped += len(keys)
1646
+ _prune_keys(obj, keys)
1647
+ d = getattr(obj, "__dict__", None)
1648
+ if d is not None:
1649
+ for attr in ("__live_return_line__", "__live_error_line__",
1650
+ "__live_touched__", "__live_accum__",
1651
+ "__live_dim_names__"):
1652
+ d.pop(attr, None)
1653
+ return dropped
1654
+
1655
+
1656
+ # ── frame snapshots (context-menu capture -> live-value stores) ──────────────
1657
+ # The context menu's stack entry holds every called frame's fscope for one
1658
+ # snapshot. Publishing them through the SAME site/key pipeline the instrumented
1659
+ # twin uses makes them first-class live values: markers, live value windows,
1660
+ # watchers, voxel, and FuncsMetadata typing all come along for free, in
1661
+ # ANY editor that shows the function - no new rendering machinery.
1662
+
1663
+ def publish_frame_snapshot(fn, scope, upto_lineno=None):
1664
+ """Publish a ``{name: value}`` scope snapshot into ``fn``'s live-value
1665
+ store, anchored at EVERY occurrence of each name in the def's own scope:
1666
+ parameters at their signature lines, and every Name reference — Load and
1667
+ Store alike (assignments in all forms, loop/with targets, walrus, and
1668
+ plain reads) — so any mention of a local in the source is a live view,
1669
+ not just its binding. One key per (name, line); all of a name's markers
1670
+ show the same captured value. ``upto_lineno`` is accepted for API
1671
+ stability but no longer gates anchors: the snapshot IS the frame's state
1672
+ at capture, and every reference line is an equally valid place to
1673
+ inspect it.
1674
+
1675
+ Deliberately NO site resolution: keys are synthesized ``line:N#name``
1676
+ tails, which the overlay anchors by line and boxes by label. The
1677
+ structural key `_resolve_site` derives costs a libcst parse of the whole
1678
+ enclosing span (~240ms for draw_collection, the whole CLASS for a
1679
+ method) and produces a prefix the overlay ignores for line-keyed
1680
+ entries. Total cost here: one cached whole-file ast + one walk of the
1681
+ def + N dict writes.
1682
+
1683
+ Keys this publisher created in a PREVIOUS snapshot that this one didn't
1684
+ re-touch are pruned (``__frame_snapshot_keys__`` on ``fn``) so edits
1685
+ between menu-opens can't leave ghost markers; keys owned by other
1686
+ writers (manual live_view calls, twin runs) are never touched.
1687
+
1688
+ Accepts a render_func WRAPPER too — unwrapped here, since anchors and
1689
+ the store must live on the real body function (the wrapper's __code__
1690
+ points at core_render)."""
1691
+ fn, items, _def_line = _frame_snapshot_items(fn, scope)
1692
+ if fn is None:
1693
+ return
1694
+ new_keys = set()
1695
+ for disk, n, val in items:
1696
+ site = _Site((f"line:{disk}#{n}",), None, None, fn, disk)
1697
+ _publish(site, val, n, bare=False)
1698
+ new_keys.add(site.key_path)
1699
+ try:
1700
+ prev = vars(fn).get("__frame_snapshot_keys__") or set()
1701
+ _prune_keys(fn, [k for k in prev - new_keys])
1702
+ vars(fn)["__frame_snapshot_keys__"] = new_keys
1703
+ except (AttributeError, TypeError):
1704
+ pass
1705
+
1706
+
1707
+ def _frame_snapshot_items(fn, scope, tree=None):
1708
+ """(unwrapped_fn, [(disk_lineno, name, value), ...], def_line) anchoring
1709
+ a ``{name: value}`` scope onto every occurrence of each captured name in
1710
+ ``fn``'s def (see _occurrence_lines) — the pure computation shared by
1711
+ publish_frame_snapshot (global store) and frame_value_store (local
1712
+ store). `def_line` is the DISK line of the `def` STATEMENT itself (the
1713
+ ast node's lineno — NOT co_firstlineno, which points at the first
1714
+ decorator for a decorated function). (None, [], None) when the
1715
+ function/scope can't be resolved.
1716
+
1717
+ A caller that already parsed the file passes `tree` — the stack view
1718
+ hands its project_code ast: no second whole-file parse (the tab-open
1719
+ cost), and the anchor lines come out in THAT text's own coordinates
1720
+ (no pending delta — the caller renders the very same text)."""
1721
+ try:
1722
+ fn = inspect.unwrap(fn)
1723
+ except Exception:
1724
+ pass
1725
+ code = getattr(fn, "__code__", None)
1726
+ if code is None or not isinstance(fn, types.FunctionType) or not scope:
1727
+ return None, [], None
1728
+ if tree is not None:
1729
+ delta = 0
1730
+ else:
1731
+ path = Path(code.co_filename).resolve()
1732
+ try:
1733
+ tree, _text, _sig = _ast_for(path, path.stat().st_mtime)
1734
+ except (OSError, SyntaxError, ValueError):
1735
+ return None, [], None
1736
+ delta = _stamp_delta(path, code.co_firstlineno) # pending = disk + Δ
1737
+ fdef = _def_node_for(tree, fn, code.co_firstlineno + delta)
1738
+ if fdef is None:
1739
+ return None, [], None
1740
+ anchors = _occurrence_lines(fdef)
1741
+ items = []
1742
+ for n, lns in anchors.items():
1743
+ if "." in n:
1744
+ # Attribute chain (`draw_state.some_val`, each segment of
1745
+ # `a.b.c` anchors separately): resolve the value through the
1746
+ # captured base object - instance __dict__ / plain class attrs
1747
+ # only, never through properties or descriptors (a DrawState
1748
+ # geometry getter must be run on the main worker).
1749
+ ok, val = _resolve_dotted(scope, n)
1750
+ if not ok:
1751
+ continue
1752
+ elif n in scope:
1753
+ val = scope[n]
1754
+ else:
1755
+ continue
1756
+ for ln in lns:
1757
+ items.append((ln - delta, n, val))
1758
+ return fn, items, fdef.lineno - delta
1759
+
1760
+
1761
+ class LocalValueStore:
1762
+ """A live-value store computed locally and PASSED IN (draw_text's
1763
+ ``live_store=``) instead of published onto the live function. Same
1764
+ attach-to-object shape the whole live-view stack reads — values, labels
1765
+ and watchers all ride this object (live_values_for, label_for, watch) —
1766
+ so the overlay/marker/window machinery works unchanged, but nothing
1767
+ global ever sees it: two views of two different captures of the same
1768
+ function coexist, and dropping the store releases everything it pinned.
1769
+ `__def_line__` (disk line of the owning def) is how the snapshot overlay
1770
+ matches the store to ITS def node and no other."""
1771
+
1772
+ def __init__(self, qualname="?", def_line=None):
1773
+ self.__qualname__ = qualname
1774
+ self.__name__ = qualname.rsplit(".", 1)[-1]
1775
+ self.__def_line__ = def_line
1776
+ self.__live_values__ = {}
1777
+ self.__live_labels__ = {}
1778
+
1779
+
1780
+ def frame_value_store(fn, scope, tree=None):
1781
+ """A LocalValueStore of ``scope`` anchored on ``fn``'s def — the same
1782
+ anchor computation publish_frame_snapshot uses, with NOTHING written to
1783
+ ``fn`` and nothing registered globally. For callers that own their
1784
+ capture (the stack trace view): build once, hand to draw_text as
1785
+ ``live_store=``, drop when done. Pass `tree` (an already-parsed ast of
1786
+ the text being rendered) to skip the whole-file parse and anchor in
1787
+ that text's coordinates. Returns None when unresolvable."""
1788
+ fn, items, def_line = _frame_snapshot_items(fn, scope, tree=tree)
1789
+ if fn is None or not items:
1790
+ return None
1791
+ # def_line is the ast def statement's line - co_firstlineno points at
1792
+ # the first DECORATOR for a decorated function (every _render_func
1793
+ # view), and the overlay's def-match gate compares against the span
1794
+ # parse tree which starts at the def itself: stamping the decorator line
1795
+ # made the gate reject the store and no marker ever drew (08-31).
1796
+ store = LocalValueStore(qualname=getattr(fn, "__qualname__", fn.__name__),
1797
+ def_line=def_line)
1798
+ for disk, n, val in items:
1799
+ key_path = (f"line:{disk}#{n}",)
1800
+ store.__live_values__[key_path] = val
1801
+ store.__live_labels__[key_path] = n
1802
+ return store
1803
+
1804
+
1805
+ def _def_node_for(tree, fn, target_lineno):
1806
+ """``fn``'s FunctionDef in the (pending-text) ast: name match, def line
1807
+ nearest ``target_lineno`` — the co_firstlineno proximity trick
1808
+ live_instrument uses, tolerant of the decorator-line offset. Scans
1809
+ module and class bodies only (a full ast.walk is O(file nodes) per
1810
+ call, and _enclosing_function can't resolve deeper functions anyway)."""
1811
+ best, best_d = None, None
1812
+ want = getattr(fn, "__name__", None)
1813
+
1814
+ def consider(node):
1815
+ nonlocal best, best_d
1816
+ d = abs(node.lineno - target_lineno)
1817
+ if best_d is None or d < best_d:
1818
+ best, best_d = node, d
1819
+
1820
+ for stmt in tree.body:
1821
+ if (isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef))
1822
+ and stmt.name == want):
1823
+ consider(stmt)
1824
+ elif isinstance(stmt, ast.ClassDef):
1825
+ for sub in stmt.body:
1826
+ if (isinstance(sub, (ast.FunctionDef, ast.AsyncFunctionDef))
1827
+ and sub.name == want):
1828
+ consider(sub)
1829
+ return best
1830
+
1831
+
1832
+ # Scopes a binding walk must NOT descend into: their Store names bind in a
1833
+ # DIFFERENT frame (nested defs/classes, lambdas, comprehensions).
1834
+ _FOREIGN_SCOPES = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef,
1835
+ ast.Lambda, ast.ListComp, ast.SetComp, ast.DictComp,
1836
+ ast.GeneratorExp)
1837
+
1838
+
1839
+ def _occurrence_lines(fdef):
1840
+ """``{name: set of pending linenos}`` of every occurrence of a name in
1841
+ ``fdef``'s own scope: parameters at their signature lines, then EVERY
1842
+ Name node — Store and Load alike — so references anchor live views, not
1843
+ just bindings. (The publisher filters to names actually captured in the
1844
+ frame's scope, which is also what keeps module globals like `imgui` out:
1845
+ they're Load names here but never frame locals.) Pure ATTRIBUTE chains
1846
+ off a Name (`draw_state.some_val`, and each inner segment of `a.b.c`)
1847
+ anchor too, under their dotted spelling — single-line chains only (the
1848
+ overlay boxes the final segment by regex on its line) and never through
1849
+ calls/subscripts (`foo().x` has no frame-resolvable base). Nested
1850
+ defs/classes/lambdas/comprehensions are not descended — their names
1851
+ live in other frames."""
1852
+ anchors = {}
1853
+ a = fdef.args
1854
+ params = list(a.posonlyargs) + list(a.args) + list(a.kwonlyargs)
1855
+ for extra in (a.vararg, a.kwarg):
1856
+ if extra is not None:
1857
+ params.append(extra)
1858
+ for arg in params:
1859
+ anchors.setdefault(arg.arg, set()).add(arg.lineno)
1860
+
1861
+ def walk(node):
1862
+ for child in ast.iter_child_nodes(node):
1863
+ if isinstance(child, _FOREIGN_SCOPES):
1864
+ continue
1865
+ if isinstance(child, ast.Name):
1866
+ anchors.setdefault(child.id, set()).add(child.lineno)
1867
+ elif (isinstance(child, ast.Attribute)
1868
+ and getattr(child, "end_lineno", child.lineno) == child.lineno):
1869
+ dotted = _dotted_name(child)
1870
+ if dotted is not None:
1871
+ anchors.setdefault(dotted, set()).add(child.lineno)
1872
+ walk(child)
1873
+
1874
+ walk(ast.Module(body=fdef.body, type_ignores=[]))
1875
+ return anchors
1876
+
1877
+
1878
+ def _dotted_name(node):
1879
+ """`a.b.c` for a pure Name-rooted attribute chain, else None (a call,
1880
+ subscript or literal anywhere in the chain has no frame-local base)."""
1881
+ parts = []
1882
+ while isinstance(node, ast.Attribute):
1883
+ parts.append(node.attr)
1884
+ node = node.value
1885
+ if isinstance(node, ast.Name):
1886
+ parts.append(node.id)
1887
+ return ".".join(reversed(parts))
1888
+ return None
1889
+
1890
+
1891
+ def _safe_attr_value(base, attr):
1892
+ """(ok, value) of ``base.attr`` WITHOUT running any code: instance
1893
+ __dict__ first, then a plain class attribute via getattr_static —
1894
+ properties, descriptors, and class-level functions (methods) are
1895
+ refused rather than fired or published as noise."""
1896
+ d = getattr(base, "__dict__", None)
1897
+ if isinstance(d, dict) and attr in d:
1898
+ return True, d[attr]
1899
+ try:
1900
+ static = inspect.getattr_static(base, attr)
1901
+ except Exception:
1902
+ return False, None
1903
+ if isinstance(static, (staticmethod, classmethod, property)):
1904
+ return False, None
1905
+ if (inspect.isfunction(static) or inspect.ismethoddescriptor(static)
1906
+ or inspect.isdatadescriptor(static) or inspect.isbuiltin(static)):
1907
+ return False, None
1908
+ return True, static
1909
+
1910
+
1911
+ def _resolve_dotted(scope, dotted):
1912
+ """(ok, value) of a dotted occurrence resolved from the captured frame
1913
+ scope — the base must be a captured local, every hop must pass
1914
+ _safe_attr_value."""
1915
+ parts = dotted.split(".")
1916
+ if parts[0] not in scope:
1917
+ return False, None
1918
+ obj = scope[parts[0]]
1919
+ for seg in parts[1:]:
1920
+ ok, obj = _safe_attr_value(obj, seg)
1921
+ if not ok:
1922
+ return False, None
1923
+ return True, obj
1924
+
1925
+
1926
+ # Serializes snapshot workers: rapid menu-opens must not interleave two
1927
+ # publishes' shared bookkeeping on the same function.
1928
+ _snapshot_lock = threading.Lock()
1929
+
1930
+
1931
+ def publish_stack_locals(frames, extra_snapshots=None):
1932
+ """The context-menu capture hook: publish every real caller frame's
1933
+ locals into that function's live-value store (see publish_frame_snapshot).
1934
+ ``frames`` is the raw get_live_frames output — entry[4] is the frame's
1935
+ f_locals copy. ``extra_snapshots`` is a list of extra (fn, scope,
1936
+ upto_lineno) publishes to run in the same batch (the capture site adds
1937
+ the TARGET view function's entry scope).
1938
+
1939
+ Runs on a short-lived daemon worker: a first publish's site resolution
1940
+ parses the enclosing span through libcst (one linemap per function,
1941
+ cached per file-gen; a METHOD's span is its whole class) — far too heavy
1942
+ for the render thread at menu-open. The store/watcher machinery is
1943
+ worker-safe by design (the instrumented twin publishes from workers);
1944
+ markers appear a beat after the menu via the normal watcher wake."""
1945
+ threading.Thread(target=_publish_stack_locals_sync,
1946
+ args=(frames, extra_snapshots),
1947
+ name="lv-frame-snapshot", daemon=True).start()
1948
+
1949
+
1950
+ def _publish_stack_locals_sync(frames, extra_snapshots=None):
1951
+ """Worker body of publish_stack_locals. Dispatch machinery and
1952
+ non-project source are skipped, and a frame whose resolved function's
1953
+ name doesn't match (lambdas, comprehensions) is dropped rather than
1954
+ mis-published. Each item ALSO records its full scope's runtime types
1955
+ into FuncsMetadata here (aliases like ds/value included — the anchored
1956
+ publishes only cover source-bound names), so the capture site pays for
1957
+ nothing but the stack grab itself. Best-effort per item — a resolution
1958
+ hiccup must never break the batch."""
1959
+ from meltygui.code.chain_converters import _is_dispatch_frame
1960
+ from meltygui.code.chain_converters import _enclosing_function
1961
+ from meltygui.code.fileref import is_editable_source
1962
+ from meltygui.core.rendering.func_metadata import FuncsMetadata
1963
+ with _snapshot_lock:
1964
+ for entry in frames or ():
1965
+ if len(entry) < 5 or not entry[4]:
1966
+ continue
1967
+ filename, lineno, func_name = entry[0], entry[1], entry[2]
1968
+ try:
1969
+ if (_is_dispatch_frame(filename, func_name)
1970
+ or not is_editable_source(filename)):
1971
+ continue
1972
+ fn = _enclosing_function(filename, lineno)
1973
+ if fn is None or getattr(fn, "__name__", None) != func_name:
1974
+ continue
1975
+ FuncsMetadata.record(fn, entry[4])
1976
+ publish_frame_snapshot(fn, entry[4], upto_lineno=lineno)
1977
+ except Exception:
1978
+ continue
1979
+ for fn, scope, upto in extra_snapshots or ():
1980
+ try:
1981
+ FuncsMetadata.record(fn, scope)
1982
+ publish_frame_snapshot(fn, scope, upto_lineno=upto)
1983
+ except Exception:
1984
+ continue
1985
+
1986
+
1987
+ def _stamp_delta(path, anchor_line):
1988
+ """Stamp→pending line bridge. Line stamps (twin snapshot calls, editor
1989
+ overlay lookups) are DISK-anchored — enclosing span's disk start +
1990
+ pending-relative offset, the co_firstlineno invariant — while _ast_for's
1991
+ tree is the PENDING text. The difference is the net shift of queued
1992
+ edits fully above the enclosing def, so callers anchor at the DEF start
1993
+ (co_firstlineno / the resolved function), never at the stamp itself: a
1994
+ grown function's stamps can sit past its own span's disk end, which
1995
+ would wrongly count the span's own edit into the delta."""
1996
+ from meltygui.code.live_instrument import _delta_above
1997
+ from meltygui.code.live_instrument import _pending_gen
1998
+ if not _pending_gen(str(path)):
1999
+ return 0
2000
+ return _delta_above(str(path), anchor_line)
2001
+
2002
+
2003
+ def _resolve_site(code, lineno):
2004
+ """The slow once-per-(code, line) path: locate the enclosing top-level
2005
+ statement via ast, run the dict conversion on just that span, and derive
2006
+ this call's key, the preceding assignment's name, and the store object.
2007
+ Tree/LineMap lookups use pending coords (line_p); the store resolution
2008
+ and the published line:N keys keep the raw DISK-anchored stamp — live
2009
+ co_firstlineno values and the editor overlay both speak that
2010
+ convention."""
2011
+ from meltygui.code.chain_converters import _enclosing_function
2012
+ from meltygui.code.chain_converters import _module_for_file
2013
+
2014
+ path = Path(code.co_filename).resolve()
2015
+ mtime = path.stat().st_mtime
2016
+ tree, text, sig = _ast_for(path, mtime)
2017
+ line_p = lineno + _stamp_delta(
2018
+ path, code.co_firstlineno
2019
+ if code.co_flags & inspect.CO_OPTIMIZED else lineno)
2020
+ var_name = _previous_assign_name(tree, line_p)
2021
+
2022
+ # A class body or method frame is not CO_OPTIMIZED; _enclosing_function
2023
+ # would wrongly pick the nearest def ABOVE such a line (it has the end
2024
+ # check), so only function frames attach to a function.
2025
+ store_obj = None
2026
+ store_is_module = True
2027
+ if code.co_flags & inspect.CO_OPTIMIZED:
2028
+ store_obj = _enclosing_function(code.co_filename, lineno)
2029
+ store_is_module = store_obj is None
2030
+ if store_obj is None:
2031
+ store_obj = _module_for_file(path)
2032
+
2033
+ try:
2034
+ loop_dims = _loop_dims_at(tree, line_p)
2035
+ except Exception:
2036
+ loop_dims = () # never let loop context kill site resolution
2037
+
2038
+ span = _top_level_span(tree, line_p)
2039
+ lm = _linemap_for(path, sig, span, text)
2040
+ arg_label = None
2041
+
2042
+ ref = _live_view_ref(lm, line_p)
2043
+ if ref is not None:
2044
+ key_path = _store_relative(ref.path, store_obj, store_is_module)
2045
+ arg_label = _arg_source(ref.value)
2046
+ else:
2047
+ # The line isn't surfaced by a bare call: either it's an
2048
+ # assignment form (y = live_view(x)`, keyed by its target - a real
2049
+ # statement key, keep it) or it sits in a body the dict conversion
2050
+ # doesn't extract (while/try/catch, nested def). The latter resolves
2051
+ # to a CONTAINER, which would conflict across sites - qualify by line.
2052
+ fallback = lm.node_at_line(line_p, absolute=True)
2053
+ if fallback is not None:
2054
+ full_path, call_node = _truncate_into_call(lm.root, fallback.path)
2055
+ key_path = _store_relative(full_path, store_obj, store_is_module)
2056
+ # arg_label means "the explicit live_view(expr) argument" - only a
2057
+ # live_view call may supply it. The truncation cuts at ANY CallParse
2058
+ # the path hits, so a snapshot stamp on `x = obj.method("lit")`
2059
+ # lands here on the RHS call - reading ITS first arg produced a
2060
+ # bogus label ('"lit"'), which also blocked _record_scope_type's
2061
+ # trust-the-injected-name rule (arg_label must be None for stamps).
2062
+ arg_label = (_arg_source(call_node)
2063
+ if _is_live_view_callparse(call_node) else None)
2064
+ if (call_node is None and isinstance(fallback.value, dict)
2065
+ and fallback.span.start_line != line_p - lm.line_offset):
2066
+ # The line landed in an enclosing CONTAINER (a while/with
2067
+ # body the dict conversion doesn't surface) - that key would
2068
+ # collide across every site in the block, so qualify by
2069
+ # line. A hit whose statement STARTS at this line is the
2070
+ # statement's bare entry - including a dict-VALUED assignment
2071
+ # (`x = {...}`: value is a dict instance, but it's a call) -
2072
+ # and keeps its clean statement key.
2073
+ key_path = key_path + (f"line:{lineno}",)
2074
+ else:
2075
+ key_path = (f"line:{lineno}",)
2076
+ if not key_path:
2077
+ key_path = (f"line:{lineno}",)
2078
+ if arg_label is None:
2079
+ # No CallParse to read the arg from (un-surfaced body) - use the ast.
2080
+ arg_label = _arg_source_ast(tree, line_p)
2081
+ return _Site(key_path, var_name, arg_label, store_obj, lineno, loop_dims)
2082
+
2083
+
2084
+ def _loop_dims_at(tree, lineno):
2085
+ """Auto dim names of the loops enclosing `lineno`, outermost first,
2086
+ within the line's innermost frame scope — a def/class boundary resets
2087
+ the chain (its body publishes from a different frame, so outer loops
2088
+ don't repeat ITS sites). Only a loop's BODY iterates: a line in its
2089
+ `else:` runs once and is not counted, and the header line itself
2090
+ (`for x in live_view(seq):`) contributes nothing."""
2091
+ dims, body = [], tree.body
2092
+ while True:
2093
+ stmt = next(
2094
+ (s for s in body
2095
+ if s.lineno <= lineno <= getattr(s, "end_lineno", s.lineno)),
2096
+ None)
2097
+ if stmt is None:
2098
+ return tuple(dims)
2099
+ if isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef,
2100
+ ast.ClassDef)):
2101
+ dims = []
2102
+ next_body = None
2103
+ for block, iterates in _stmt_blocks(stmt):
2104
+ if any(s.lineno <= lineno <= getattr(s, "end_lineno", s.lineno)
2105
+ for s in block):
2106
+ if iterates:
2107
+ dims.append(_loop_name(stmt))
2108
+ next_body = block
2109
+ break
2110
+ if next_body is None:
2111
+ return tuple(dims)
2112
+ body = next_body
2113
+
2114
+
2115
+ def _stmt_blocks(stmt):
2116
+ """(statement block, iterates) pairs for every block of `stmt` — only a
2117
+ For/While `body` iterates."""
2118
+ is_loop = isinstance(stmt, (ast.For, ast.AsyncFor, ast.While))
2119
+ for field in _BODY_FIELDS:
2120
+ sub = getattr(stmt, field, None)
2121
+ if isinstance(sub, list) and sub:
2122
+ yield sub, is_loop and field == "body"
2123
+ for handler in getattr(stmt, "handlers", None) or []:
2124
+ yield handler.body, False
2125
+ for case in getattr(stmt, "cases", None) or []:
2126
+ yield case.body, False
2127
+
2128
+
2129
+ def _loop_name(stmt):
2130
+ """The auto dim name for one loop: the first plain Name in a for-target
2131
+ ('i' for `for i, layer in enumerate(…)` — the index var by position),
2132
+ 'iter' for a while (no target to name it by)."""
2133
+ target = getattr(stmt, "target", None)
2134
+ if target is not None:
2135
+ for node in ast.walk(target):
2136
+ if isinstance(node, ast.Name):
2137
+ return node.id
2138
+ return "iter"
2139
+
2140
+
2141
+ def _ast_for(path, mtime):
2142
+ """(tree, text, sig) of the file's IN-MEMORY source — disk with every
2143
+ queued (unsaved) span edit spliced in (PendingSave.current_file_text:
2144
+ the same text the twin and Ctrl+Enter's hotswap compile). live_view must
2145
+ never parse raw disk text: deferred saves leave disk stale mid-session,
2146
+ and resolving keys against the pre-edit layout is exactly the
2147
+ adjacent-line key jumbling / bare line:N fallback bug. Cache signature =
2148
+ (mtime, pending gen) — both cheap; the O(file) splice runs on miss
2149
+ only."""
2150
+ from meltygui.code.live_instrument import _pending_gen
2151
+ key = str(path)
2152
+ gen = _pending_gen(key)
2153
+ sig = (mtime, gen)
2154
+ cached = _asts.get(key)
2155
+ if cached is not None and cached[0] == sig:
2156
+ return cached[1], cached[2], sig
2157
+ text = None
2158
+ if gen:
2159
+ from meltygui.editor.pending_save import PendingSave
2160
+ text = PendingSave.current_file_text(path)
2161
+ if text is None:
2162
+ text = path.read_text()
2163
+ _t0 = time.perf_counter()
2164
+ tree = ast.parse(text)
2165
+ try: # TEMP perf: how often the full-file reparse actually fires
2166
+ from meltygui.core.diagnostics.perf_trace import trace as _pt
2167
+ _pt("live_view ast reparse", path=path.name, gen=gen,
2168
+ ms=round((time.perf_counter() - _t0) * 1000.0, 1))
2169
+ except Exception:
2170
+ pass
2171
+ _asts[key] = (sig, tree, text)
2172
+ return tree, text, sig
2173
+
2174
+
2175
+ def _top_level_span(tree, lineno):
2176
+ """(start, end) 1-indexed file lines of the top-level statement containing
2177
+ `lineno` — the def/class (decorators included) whose span the dict
2178
+ conversion runs on. None when no statement contains the line."""
2179
+ for stmt in tree.body:
2180
+ end = getattr(stmt, "end_lineno", stmt.lineno)
2181
+ start = min([stmt.lineno] + [d.lineno for d in
2182
+ getattr(stmt, "decorator_list", [])])
2183
+ if start <= lineno <= end:
2184
+ return start, end
2185
+ return None
2186
+
2187
+
2188
+ def _linemap_for(path, sig, span, text):
2189
+ """The LineMap of one top-level statement's span (whole file when span is
2190
+ None), rebuilt when `sig` — _ast_for's (mtime, pending gen) — changes,
2191
+ so queued edits that never touch disk still invalidate. Span-bounded so
2192
+ a save re-parses one function, not the file — the whole-file position
2193
+ pass holds the GIL for ~1s on big modules (see chain_converters'
2194
+ measurement) and this runs on the calling thread."""
2195
+ import libcst as cst
2196
+ from meltygui.code.libcst_conversion import LineMap
2197
+ from meltygui.code.libcst_conversion import cst_module_to_dict
2198
+
2199
+ start = span[0] if span else 1
2200
+ key = (str(path), start)
2201
+ cached = _linemaps.get(key)
2202
+ if cached is not None and cached[0] == sig:
2203
+ return cached[1]
2204
+ if span is None:
2205
+ snippet = text
2206
+ else:
2207
+ lines = text.splitlines(keepends=True)
2208
+ snippet = "".join(lines[span[0] - 1:span[1]])
2209
+ from meltygui.core.runtime.toggles import Toggles
2210
+ if Toggles.TextEditor.melty_syntax:
2211
+ parse = cst_module_to_dict(snippet) # core_syntax (raw input)
2212
+ else:
2213
+ parse = cst_module_to_dict(cst.parse_module(snippet))
2214
+ lm = LineMap(parse, line_offset=start - 1)
2215
+ _linemaps[key] = (sig, lm)
2216
+ return lm
2217
+
2218
+
2219
+ def _live_view_ref(lm, lineno):
2220
+ """The NodeRef of the live_view CALL whose span contains `lineno` — matched
2221
+ by key, not node_at_line, because the deepest node at the line is usually
2222
+ an ARG inside the CallParse. Deepest match wins. (Reads LineMap's _entries
2223
+ index directly — in-package machinery, same data node_at_line scans.)"""
2224
+ rel_line = lineno - lm.line_offset
2225
+ best = None
2226
+ for span, depth, ref in lm._entries:
2227
+ if not (span.start_line <= rel_line <= span.end_line):
2228
+ continue
2229
+ if not isinstance(ref.key, str):
2230
+ continue
2231
+ if ref.key.split("#", 1)[0] != "live_view()":
2232
+ continue
2233
+ if best is None or depth > best[0]:
2234
+ best = (depth, ref)
2235
+ return best[1] if best else None
2236
+
2237
+
2238
+ def _truncate_into_call(root, path):
2239
+ """Cut `path` segments that descend INSIDE a call's argument dict — the
2240
+ statement key is the site's address; which argument the line landed on is
2241
+ not. Returns (path, call_parse) where call_parse is the CallParse the path
2242
+ was cut at (None if the path never enters one)."""
2243
+ from meltygui.code.libcst_conversion import CallParse
2244
+ node = root
2245
+ for i, seg in enumerate(path):
2246
+ node = node.get(seg) if isinstance(node, dict) else None
2247
+ if isinstance(node, CallParse):
2248
+ return tuple(path[:i + 1]), node
2249
+ if node is None:
2250
+ break
2251
+ return tuple(path), None
2252
+
2253
+
2254
+ def _owning_def_name(path):
2255
+ """The name of the innermost def a key path crosses (`..., <name>,
2256
+ "locals", ...`), or None for module/class-level paths."""
2257
+ for i in range(len(path) - 1, 0, -1):
2258
+ if path[i] == "locals":
2259
+ return path[i - 1]
2260
+ return None
2261
+
2262
+
2263
+ def _store_relative(path, store_obj, store_is_module):
2264
+ """Key path relative to the STORE OBJECT's scope. For a function store,
2265
+ cut at the first `<func name>, "locals"` pair — nested-def segments stay,
2266
+ so two closures' sites on the same outer function can't collide, and the
2267
+ editor parsing that function's span sees the same paths. Module stores
2268
+ keep the full path."""
2269
+ if store_is_module:
2270
+ return tuple(path)
2271
+ fn_name = getattr(store_obj, "__name__", None)
2272
+ for i in range(len(path) - 1):
2273
+ if path[i] == fn_name and path[i + 1] == "locals":
2274
+ return tuple(path[i + 2:])
2275
+ # Renamed wrapper / span mismatch: fall back to the innermost scope cut.
2276
+ for i in range(len(path) - 1, -1, -1):
2277
+ if path[i] == "locals":
2278
+ return tuple(path[i + 1:])
2279
+ return tuple(path)
2280
+
2281
+
2282
+ def _is_live_view_callparse(call_parse):
2283
+ """True when a CallParse is a `live_view(...)` call. Guards arg_label
2284
+ resolution: the statement-path truncation cuts at whatever CallParse it
2285
+ enters first, which for a snapshot-stamped assignment is the RHS's own
2286
+ call — whose arguments have nothing to do with live_view's."""
2287
+ if not isinstance(call_parse, dict):
2288
+ return False
2289
+ func_name = getattr(call_parse, "func_name", None) # CallParse, either form
2290
+ if isinstance(func_name, str):
2291
+ return func_name.rsplit(".", 1)[-1] == "live_view"
2292
+ try:
2293
+ func = call_parse.get("__cst__").func
2294
+ except Exception:
2295
+ return False
2296
+ name = getattr(func, "value", None) # cst.Name
2297
+ if not isinstance(name, str):
2298
+ name = getattr(getattr(func, "attr", None), "value", None) # cst.Attribute
2299
+ return name == "live_view"
2300
+
2301
+
2302
+ def _arg_source(call_parse):
2303
+ """The source text of an explicit `live_view(expr)` argument, for display.
2304
+ The positional arg binds to live_view's `value` parameter when the
2305
+ signature resolves, else surfaces under a synthetic `arg0` key — take the
2306
+ first real entry either way."""
2307
+ if not isinstance(call_parse, dict):
2308
+ return None
2309
+ arg = call_parse.get("value")
2310
+ if arg is None:
2311
+ for k, v in call_parse.items():
2312
+ if isinstance(k, str) and not k.startswith("__"):
2313
+ arg = v
2314
+ break
2315
+ return str(arg) if arg is not None else None
2316
+
2317
+
2318
+ # ── previous-assignment resolution (ast) ──────────────────────────────────────
2319
+ # The display dict drops statements it can't surface (aug-assigns, tuple
2320
+ # unpacks, while/with/match bodies), so walking its keys silently skips them
2321
+ # and captures an OLDER variable. The ast sees every statement; resolve the
2322
+ # bare form against it instead.
2323
+
2324
+ def _previous_assign_name(tree, lineno):
2325
+ """The variable assigned by the statement preceding the live_view call at
2326
+ `lineno`: locate the deepest body list holding the call's statement, walk
2327
+ its preceding siblings backwards, and take the first assignment target —
2328
+ descending into a preceding block's last assignment, skipping defs."""
2329
+ found = _stmt_in_body(tree.body, lineno)
2330
+ if found is None:
2331
+ return None
2332
+ body, idx = found
2333
+ for stmt in reversed(body[:idx]):
2334
+ name = _direct_target(stmt)
2335
+ if name is _STOP:
2336
+ # The preceding statement DOES bind something we can't name (tuple
2337
+ # unpack, obj.attr / a[i] target) - publishing an older variable
2338
+ # instead would be silently losing data. Capture nothing.
2339
+ return None
2340
+ if name is not None:
2341
+ return name
2342
+ name = _last_target(stmt) # blocks capture their last assignment
2343
+ if name is not None:
2344
+ return name
2345
+ return None
2346
+
2347
+
2348
+ _BODY_FIELDS = ("body", "orelse", "finalbody")
2349
+
2350
+
2351
+ def _stmt_in_body(body, lineno):
2352
+ """(body list, index) of the statement containing a live_view call at
2353
+ `lineno`, in the DEEPEST body list that holds it."""
2354
+ for i, stmt in enumerate(body):
2355
+ end = getattr(stmt, "end_lineno", stmt.lineno)
2356
+ if not (stmt.lineno <= lineno <= end):
2357
+ continue
2358
+ for field in _BODY_FIELDS:
2359
+ sub = getattr(stmt, field, None)
2360
+ if isinstance(sub, list) and sub:
2361
+ found = _stmt_in_body(sub, lineno)
2362
+ if found is not None:
2363
+ return found
2364
+ for handler in getattr(stmt, "handlers", None) or []:
2365
+ found = _stmt_in_body(handler.body, lineno)
2366
+ if found is not None:
2367
+ return found
2368
+ for case in getattr(stmt, "cases", None) or []:
2369
+ found = _stmt_in_body(case.body, lineno)
2370
+ if found is not None:
2371
+ return found
2372
+ if _holds_live_view_call(stmt):
2373
+ return body, i
2374
+ return None
2375
+ return None
2376
+
2377
+
2378
+ def _arg_source_ast(tree, lineno):
2379
+ """The explicit argument's source for a live_view call at `lineno`, read
2380
+ from the ast — the label fallback for calls in bodies the dict conversion
2381
+ doesn't surface (while/with/match, nested defs)."""
2382
+ for node in ast.walk(tree):
2383
+ if not isinstance(node, ast.Call) or not node.args:
2384
+ continue
2385
+ end = getattr(node, "end_lineno", node.lineno)
2386
+ if not (node.lineno <= lineno <= end):
2387
+ continue
2388
+ func = node.func
2389
+ name = (func.id if isinstance(func, ast.Name)
2390
+ else func.attr if isinstance(func, ast.Attribute) else None)
2391
+ if name == "live_view":
2392
+ return ast.unparse(node.args[0])
2393
+ return None
2394
+
2395
+
2396
+ def _holds_live_view_call(stmt):
2397
+ for node in ast.walk(stmt):
2398
+ if isinstance(node, ast.Call):
2399
+ func = node.func
2400
+ name = (func.id if isinstance(func, ast.Name)
2401
+ else func.attr if isinstance(func, ast.Attribute) else None)
2402
+ if name == "live_view":
2403
+ return True
2404
+ return False
2405
+
2406
+
2407
+ _STOP = object()
2408
+
2409
+
2410
+ def _direct_target(stmt):
2411
+ """A direct assignment's target name; _STOP for an assignment whose target
2412
+ can't be named as a frame local (tuple unpack, obj.attr, a[i]); None for a
2413
+ non-binding statement (defs and classes bind names whose body locals are
2414
+ not in the caller's frame — non-binding here)."""
2415
+ if isinstance(stmt, ast.Assign):
2416
+ for target in stmt.targets: # p = q = v → the first plain-name target
2417
+ if isinstance(target, ast.Name):
2418
+ return target.id
2419
+ return _STOP
2420
+ if isinstance(stmt, (ast.AugAssign, ast.AnnAssign)):
2421
+ return stmt.target.id if isinstance(stmt.target, ast.Name) else _STOP
2422
+ return None
2423
+
2424
+
2425
+ def _last_target(stmt):
2426
+ """The LAST direct assignment name anywhere inside a block statement, in
2427
+ source order — what a bare live_view right after an if/for/try captures."""
2428
+ last = None
2429
+ direct = _direct_target(stmt)
2430
+ if direct is not None:
2431
+ return direct if direct is not _STOP else None
2432
+ if isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
2433
+ return None
2434
+ for field in _BODY_FIELDS:
2435
+ for sub in getattr(stmt, field, None) or []:
2436
+ name = _last_target(sub)
2437
+ if name is not None:
2438
+ last = name
2439
+ for handler in getattr(stmt, "handlers", None) or []:
2440
+ for sub in handler.body:
2441
+ name = _last_target(sub)
2442
+ if name is not None:
2443
+ last = name
2444
+ return last
2445
+
2446
+
2447
+ def publish_external_values(filename, source, name, values):
2448
+ """Attach project-process snapshots to the same owner the inline UI reads.
2449
+
2450
+ Only a no-op function is compiled locally: project imports, decorators,
2451
+ defaults and user code belong exclusively to the project interpreter.
2452
+ Values are (source line, variable name, decoded value) triples.
2453
+ """
2454
+ import hashlib
2455
+ from meltygui.code import chain_converters as cc
2456
+ from meltygui.code.live_instrument import _delta_above
2457
+
2458
+ path = Path(filename).resolve()
2459
+ node = next(n for n in ast.parse(source).body
2460
+ if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)) and n.name == name)
2461
+ start = min([node.lineno] + [d.lineno for d in node.decorator_list])
2462
+ delta = _delta_above(str(path), start)
2463
+ delta = _delta_above(str(path), max(1, start - delta))
2464
+ disk_start = max(1, node.lineno - delta)
2465
+ modules = cc._modules_for_file(path)
2466
+ if modules:
2467
+ module = modules[0]
2468
+ else:
2469
+ module_name = '_melty_fnrun_' + hashlib.sha256(str(path).encode()).hexdigest()
2470
+ module = types.ModuleType(module_name)
2471
+ module.__file__ = str(path)
2472
+ sys.modules[module_name] = module
2473
+ slot = f'_fnrun_live_{name}'
2474
+ previous = vars(module).get(slot)
2475
+ namespace = {'__name__': module.__name__}
2476
+ exec(compile('\n' * (disk_start - 1) + f'def {name}():\n pass\n',
2477
+ str(path), 'exec'), namespace)
2478
+ owner = namespace[name]
2479
+ owner.__fnrun_exec__ = True
2480
+ adopt_live_store(previous, owner)
2481
+ vars(module)[slot] = owner
2482
+ for key in list(cc._ENCLOSING_FN_CACHE):
2483
+ if key[0] == str(path):
2484
+ del cc._ENCLOSING_FN_CACHE[key]
2485
+ with run_capture(owner):
2486
+ for line, label, value in values:
2487
+ disk_line = line - delta
2488
+ site = _Site((f'line:{disk_line}#{label}',), None, None, owner, disk_line)
2489
+ _publish(site, value, label, bare=False)
2490
+ return owner