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,1933 @@
1
+ """
2
+ File-based converters for the Melty framework.
3
+
4
+ All converters are @render_func decorated, supporting both rendering
5
+ and background-thread converter modes. Each converter gets its own
6
+ draw_state, caching, and parameter injection.
7
+
8
+ I/O callbacks (load_text, load_file_bytes, etc.) are used as load_data
9
+ parameters on forward converters. Save handlers (recompile_fn, etc.)
10
+ are used as save_data parameters on reverse converters.
11
+ """
12
+ import ast
13
+ import builtins
14
+ from meltygui.core.diagnostics.notifications import lag_traced
15
+
16
+ import dis
17
+ import inspect
18
+ import json
19
+ import re as _re
20
+ import textwrap
21
+ import time
22
+ import types
23
+ from enum import EnumMeta
24
+ from importlib import reload
25
+
26
+ from pathlib import Path
27
+ from meltygui.core.melty import Melty
28
+ from meltygui.core.definition_hotswap import patch_function
29
+ from meltygui.core.definition_hotswap import canonicalize_definitions
30
+
31
+ import libcst as cst
32
+
33
+ from meltygui.core.windowing.glfw_utils import print_stack_trace
34
+ import meltygui.code.hotswap_guard as _hotswap_guard
35
+ from meltygui.code.fileref import Address
36
+ from meltygui.code.fileref import invalidate_address_cache
37
+ from meltygui.code.fileref import update_address_cache
38
+ from meltygui.code.fileref import is_editable_source
39
+ from meltygui.code.fileref import shift_sibling_linenos
40
+ from meltygui.code.libcst_conversion import invalidate_usage_cache
41
+
42
+
43
+ def _class_code_objects(cls: type) -> set:
44
+ """All code-object ids reachable from a class's methods — used to attribute a
45
+ runtime traceback frame back to a hotswapped class."""
46
+ ids: set = set()
47
+ for val in vars(cls).values():
48
+ fn = None
49
+ if isinstance(val, types.FunctionType):
50
+ fn = val
51
+ elif isinstance(val, (staticmethod, classmethod)):
52
+ fn = val.__func__
53
+ if fn is not None and isinstance(getattr(fn, "__code__", None), types.CodeType):
54
+ ids |= _hotswap_guard.collect_code_ids(fn.__code__)
55
+ return ids
56
+
57
+
58
+ def _snapshot_class(cls: type) -> type:
59
+ """A throwaway clone of `cls` whose members mirror the current class — copies
60
+ of each method (so its live __code__ is preserved even as the original is
61
+ patched in place) plus a snapshot of every other attribute. Rolling back means
62
+ `_hotswap_class(cls, snapshot)`, which writes these members back over cls."""
63
+ members = {}
64
+ for name, val in list(vars(cls).items()):
65
+ if name in ("__dict__", "__weakref__", "__slots__"):
66
+ continue
67
+ if isinstance(val, types.MemberDescriptorType):
68
+ # Slot descriptors: copying one alongside __slots__ means type()
69
+ # raises "conflicts with class variable", and the original class
70
+ # keeps its own descriptors anyway - nothing to snapshot.
71
+ continue
72
+ if isinstance(val, types.FunctionType):
73
+ if val.__dict__.get("__melty_relocated__"):
74
+ val = inspect.unwrap(val)
75
+ clone = types.FunctionType(val.__code__, val.__globals__, val.__name__,
76
+ val.__defaults__, val.__closure__)
77
+ clone.__kwdefaults__ = val.__kwdefaults__
78
+ clone.__annotations__ = dict(val.__annotations__ or {})
79
+ clone.__doc__ = val.__doc__
80
+ clone.__qualname__ = val.__qualname__
81
+ members[name] = clone
82
+ elif isinstance(val, (staticmethod, classmethod)):
83
+ inner = val.__func__
84
+ if inner.__dict__.get("__melty_relocated__"):
85
+ inner = inspect.unwrap(inner)
86
+ clone = types.FunctionType(inner.__code__, inner.__globals__, inner.__name__,
87
+ inner.__defaults__, inner.__closure__)
88
+ clone.__kwdefaults__ = inner.__kwdefaults__
89
+ clone.__annotations__ = dict(inner.__annotations__ or {})
90
+ clone.__qualname__ = inner.__qualname__
91
+ members[name] = type(val)(clone)
92
+ else:
93
+ members[name] = val
94
+ # Enum members are shared BY IDENTITY across the app and are patched IN
95
+ # PLACE by _reconcile_enum_members (see there) - so the plain alias stored
96
+ # here is NOT a snapshot: it's the same object, and it will already carry
97
+ # the new value by the time a rollback runs. Snapshot their state instead;
98
+ # _hotswap_class recognises this key as the rollback direction.
99
+ if isinstance(cls, EnumMeta):
100
+ members["_enum_member_state_"] = {
101
+ name: dict(vars(m)) for name, m in cls.__members__.items()}
102
+ return type(f"_snapshot_{cls.__name__}", (), members)
103
+
104
+
105
+ def _register_hotswap(source, restore, code, line_base=0):
106
+ """Register a successful hotswap with the rollback guard. `code` is either a
107
+ single code object (function) or a set of code-object ids (class/module)."""
108
+ if isinstance(code, types.CodeType):
109
+ code_ids = _hotswap_guard.collect_code_ids(code)
110
+ else:
111
+ code_ids = set(code)
112
+ _hotswap_guard.register(source, restore, code_ids, line_base=line_base)
113
+
114
+
115
+ # ╔══════════════════════════════════════════════════════════════════════════════╗
116
+ # ║ I/O callbacks (used as load_data / save_data) ║
117
+ # ╚══════════════════════════════════════════════════════════════════════════════╝
118
+
119
+ def _detect_newline(data: bytes) -> str:
120
+ """The file's DOMINANT line ending, used as the OUTPUT newline when re-joining
121
+ spliced lines. Count-based (not "CRLF if any CRLF") so a stray CRLF doesn't
122
+ flip a mostly-LF file. Splitting uses `_split_lines` (universal), not this — so
123
+ detection only decides the join, never the line boundaries."""
124
+ crlf = data.count(b"\r\n")
125
+ lone_lf = data.count(b"\n") - crlf
126
+ return "\r\n" if crlf > lone_lf else "\n"
127
+
128
+
129
+ _LINE_SPLIT_RE = _re.compile(r"\r\n|\r|\n")
130
+
131
+
132
+ def _split_lines(text: str):
133
+ """Split on UNIVERSAL newlines (CRLF / CR / LF), matching `str.split('\\n')`'s
134
+ element model (a trailing newline yields a final "" element). The span
135
+ load/save splice MUST split this way: a single detected newline (`_detect_newline`)
136
+ can mismatch the line boundaries when the file has mixed endings OR when the
137
+ libcst-emitted `code_str` uses a different newline than the file — and
138
+ `code_str.split("\\r\\n")` over LF content does NOT split, collapsing a whole
139
+ function span onto one line (the reported comment/code merge). Universal split
140
+ can never fail to break a real line, and it matches the true line numbers that
141
+ getsourcelines/co_firstlineno produce."""
142
+ return _LINE_SPLIT_RE.split(text)
143
+
144
+
145
+ def load_file_bytes(ref: Address) -> bytes:
146
+ return ref.path.read_bytes()
147
+
148
+
149
+ def load_text(ref: Address) -> str:
150
+ data = ref.path.read_bytes()
151
+ newline = _detect_newline(data)
152
+ try:
153
+ text = data.decode("utf-8")
154
+ except UnicodeDecodeError:
155
+ text = data.decode("latin-1")
156
+ lines = _split_lines(text)
157
+ return newline.join(lines[ref.start:ref.end])
158
+
159
+
160
+ def load_span_text(ref: Address) -> str:
161
+ data = ref.path.read_bytes()
162
+ newline = _detect_newline(data)
163
+ try:
164
+ text = data.decode("utf-8")
165
+ except UnicodeDecodeError:
166
+ text = data.decode("latin-1")
167
+ lines = _split_lines(text)
168
+ return newline.join(lines[ref.start:ref.end])
169
+
170
+
171
+ # ╔══════════════════════════════════════════════════════════════════════════════╗
172
+ # ║ @render_func converters ║
173
+ # ╚══════════════════════════════════════════════════════════════════════════════╝
174
+
175
+ from meltygui.core.core_render import render_func
176
+
177
+
178
+ # --- Basic type converters ---
179
+
180
+ @render_func()
181
+ def rf_path_to_bytes(input_value) -> bytes:
182
+ """Path → bytes."""
183
+ p = Path(input_value)
184
+ if not p.exists():
185
+ raise FileNotFoundError(f"No such file: {p}")
186
+ return None, p.read_bytes()
187
+
188
+
189
+ @render_func()
190
+ def rf_bytes_to_str(input_value) -> str:
191
+ """bytes → str (UTF-8, falls back to latin-1)."""
192
+ try:
193
+ return None, input_value.decode("utf-8")
194
+ except UnicodeDecodeError:
195
+ return None, input_value.decode("latin-1")
196
+
197
+
198
+ @render_func()
199
+ def rf_str_to_bytes(input_value) -> bytes:
200
+ """str → bytes (UTF-8)."""
201
+ return None, input_value.encode("utf-8")
202
+
203
+
204
+ @render_func()
205
+ def rf_str_to_dict(input_value) -> dict:
206
+ """str → dict (JSON parse)."""
207
+ try:
208
+ return None, json.loads(input_value)
209
+ except (TypeError, ValueError) as e:
210
+ return None, {"error": f"Value is not JSON-serializable: {e}"}
211
+
212
+
213
+ @render_func()
214
+ def rf_diclaudct_to_str(input_value) -> str:
215
+ """dict → str."""
216
+ try:
217
+ return None, str(input_value)
218
+ except (TypeError, ValueError) as e:
219
+ return None, f"Error serializing dict to JSON: {e}"
220
+
221
+
222
+ # --- File I/O converters ---
223
+
224
+ @render_func(load_data=load_file_bytes)
225
+ def rf_path_to_dict(input_value, data=None, ref=None) -> dict:
226
+ """Path → file metadata dict."""
227
+ return None, {
228
+ "name": ref.path.name,
229
+ "stem": ref.path.stem,
230
+ "suffix": ref.path.suffix,
231
+ "size": len(data),
232
+ "modified": ref.path.stat().st_mtime,
233
+ "data": data,
234
+ "__path__": ref.path,
235
+ "__original_data__": data,
236
+ }
237
+
238
+ # Keep old name as alias for backward compat
239
+ path_to_dict = rf_path_to_dict
240
+
241
+
242
+ @render_func()
243
+ def save_file_fn(input_value, ref=None):
244
+ """Save handler: write bytes back to disk."""
245
+ if not is_editable_source(ref.path):
246
+ print(f"[save_file_fn] refusing to write library source: {ref.path}")
247
+ return None, ref
248
+ ref.path.parent.mkdir(parents=True, exist_ok=True)
249
+ if isinstance(input_value, str):
250
+ ref.path.write_text(input_value, encoding="utf-8")
251
+ else:
252
+ ref.path.write_bytes(input_value)
253
+ invalidate_usage_cache(ref.path)
254
+ return None, ref
255
+
256
+
257
+ @render_func(save_data=save_file_fn)
258
+ def rf_dict_to_path(input_value) -> bytes:
259
+ """File metadata dict → bytes for save."""
260
+ data = input_value.get("data")
261
+ if data is None:
262
+ raise ValueError("Dict has no 'data' — nothing to write")
263
+ return None, data.encode("utf-8") if isinstance(data, str) else data
264
+
265
+
266
+ @render_func(load_data=load_span_text)
267
+ def rf_address_to_dict(input_value, data=None) -> dict:
268
+ """Address → text dict."""
269
+ return None, {
270
+ "value": data,
271
+ "__original_value__": data,
272
+ }
273
+
274
+
275
+ @render_func()
276
+ def rf_dict_to_address(input_value) -> str:
277
+ """Text dict → str for save."""
278
+ data = input_value.get("value")
279
+ if data is None:
280
+ raise ValueError("Dict has no 'value' — nothing to write")
281
+ return None, data
282
+
283
+
284
+ # --- FunctionType ↔ cst.Module ---
285
+
286
+ @render_func(load_data=load_text)
287
+ def fn_to_cst(input_value, data=None) -> cst.Module:
288
+ """Forward: function → cst.Module."""
289
+ return None, cst.parse_module(data)
290
+
291
+
292
+ @render_func()
293
+ def recompile_fn(input_value, ref=None, function_ref=None):
294
+ """Save handler: hotswap function + write source to disk."""
295
+ from meltygui.core.conversion.path_finder import Pending
296
+ from meltygui.core.conversion.path_finder import PendingState
297
+ if not is_editable_source(ref.path):
298
+ print(f"[recompile_fn] refusing to write library source: {ref.path}")
299
+ return None, ref
300
+ if function_ref is not None:
301
+ try:
302
+ _recompile(function_ref, input_value, str(ref.path))
303
+ except Exception as e:
304
+ return Pending(originated=recompile_fn, status=str(e),
305
+ state=PendingState.ERROR), None
306
+
307
+ full_data = ref.path.read_bytes()
308
+ newline = _detect_newline(full_data)
309
+ try:
310
+ text = full_data.decode("utf-8")
311
+ except UnicodeDecodeError:
312
+ text = full_data.decode("latin-1")
313
+ lines = _split_lines(text)
314
+ new_lines = _split_lines(input_value)
315
+ lines[ref.start:ref.end] = new_lines
316
+ ref.path.write_text(newline.join(lines), encoding="utf-8")
317
+ invalidate_usage_cache(ref.path)
318
+
319
+ # An edit that changes the function's line count shifts all function definitions
320
+ # BELOW it down (or up) in the file. Patch their co_firstlineno so the next
321
+ # resolve via inspect.getsourcelines returns truthful line numbers - the same
322
+ # fix _do_save applies in the chain save path. Without it a reload resolves
323
+ # to a stale span (findsource jumps back to the parent def, or to line 0 →
324
+ # wiped to head), which breaks rendering of every function below.
325
+ if ref.start is not None and ref.end is not None:
326
+ delta = (ref.start + len(new_lines)) - ref.end
327
+ shift_sibling_linenos(function_ref if function_ref is not None else ref.source,
328
+ ref.path, after_lineno=ref.end, delta=delta)
329
+
330
+ new_ref = Address(ref.path, ref.start, ref.start + len(new_lines))
331
+ yellow = "\033[93m"
332
+ reset = "\033[0m"
333
+ print(f"{yellow}[File Write] Updated file {ref.path}{reset}")
334
+ if function_ref is not None:
335
+ update_address_cache(function_ref, new_ref)
336
+ else:
337
+ invalidate_address_cache(function_ref)
338
+ return None, new_ref
339
+
340
+
341
+ @render_func(save_data=recompile_fn)
342
+ def cst_to_fn(input_value):
343
+ """Reverse: cst.Module → source string."""
344
+ return None, input_value.code
345
+
346
+
347
+ # --- types.ModuleType ↔ cst.Module ---
348
+
349
+ @render_func(load_data=load_text)
350
+ def mod_to_cst(input_value, data=None) -> cst.Module:
351
+ """Forward: module → cst.Module."""
352
+ return None, cst.parse_module(data)
353
+
354
+
355
+ @render_func()
356
+ def recompile_mod_fn(input_value, ref=None, module_ref=None):
357
+ """Save handler: hotswap module + write source to disk."""
358
+ from meltygui.core.conversion.path_finder import Pending
359
+ from meltygui.core.conversion.path_finder import PendingState
360
+ if not is_editable_source(ref.path):
361
+ print(f"[recompile_mod_fn] refusing to write library source: {ref.path}")
362
+ return None, ref
363
+ if module_ref is not None:
364
+ try:
365
+ _recompile_module(module_ref, input_value, str(ref.path))
366
+ except Exception as e:
367
+ return Pending(originated=recompile_mod_fn, status=str(e),
368
+ state=PendingState.ERROR), None
369
+ # Module Addresses cover the whole file; write directly
370
+ ref.path.write_text(input_value, encoding="utf-8")
371
+ invalidate_usage_cache(ref.path)
372
+ return None, ref
373
+
374
+
375
+ @render_func(save_data=recompile_mod_fn)
376
+ def cst_to_mod(input_value):
377
+ """Reverse: cst.Module → source string."""
378
+ return None, input_value.code
379
+
380
+ # Keep old name as alias
381
+ recompile_module = recompile_mod_fn
382
+
383
+
384
+ # --- type (class) ↔ cst.Module ---
385
+
386
+ @render_func(load_data=load_text)
387
+ def cls_to_cst(input_value, data=None) -> cst.Module:
388
+ """Forward: class → cst.Module."""
389
+ return None, cst.parse_module(data)
390
+
391
+
392
+ @render_func()
393
+ def recompile_cls_fn(input_value, ref=None, class_ref=None,
394
+ hotswap_instances=True):
395
+ """Save handler: hotswap class + write source to disk."""
396
+ from meltygui.core.conversion.path_finder import Pending
397
+ from meltygui.core.conversion.path_finder import PendingState
398
+ if not is_editable_source(ref.path):
399
+ print(f"[recompile_cls_fn] refusing to write library source: {ref.path}")
400
+ return None, ref
401
+ if class_ref is not None:
402
+ try:
403
+ _recompile_class(class_ref, input_value, str(ref.path))
404
+ except Exception as e:
405
+ return Pending(originated=recompile_cls_fn, status=str(e),
406
+ state=PendingState.ERROR), None
407
+
408
+ if hotswap_instances:
409
+ _patch_instances(class_ref)
410
+
411
+ full_data = ref.path.read_bytes()
412
+ newline = _detect_newline(full_data)
413
+ try:
414
+ text = full_data.decode("utf-8")
415
+ except UnicodeDecodeError:
416
+ text = full_data.decode("latin-1")
417
+ lines = _split_lines(text)
418
+ new_lines = _split_lines(input_value)
419
+ lines[ref.start:ref.end] = new_lines
420
+ ref.path.write_text(newline.join(lines), encoding="utf-8")
421
+ invalidate_usage_cache(ref.path)
422
+
423
+ # Shift co_firstlineno of every function/class after this one when the one's
424
+ # line count changes, so their next resolve lands on the right span (see the
425
+ # matching block in recompile_fn / _do_save). The edited class's own methods
426
+ # are handled by the recompile in _recompile_class; shift_sibling_linenos skips
427
+ # the saved class and only corrects what comes after it in the file.
428
+ if ref.start is not None and ref.end is not None:
429
+ delta = (ref.start + len(new_lines)) - ref.end
430
+ shift_sibling_linenos(class_ref if class_ref is not None else ref.source,
431
+ ref.path, after_lineno=ref.end, delta=delta)
432
+
433
+ yellow = "\033[93m"
434
+ reset = "\033[0m"
435
+ print(f"{yellow}[File Write] Updated file {ref.path}{reset}")
436
+ new_ref = Address(ref.path, ref.start, ref.start + len(new_lines))
437
+ if class_ref is not None:
438
+ update_address_cache(class_ref, new_ref)
439
+ return None, new_ref
440
+
441
+
442
+ @render_func(save_data=recompile_cls_fn)
443
+ def cst_to_cls(input_value):
444
+ """Reverse: cst.Module → source string."""
445
+ return None, input_value.code
446
+
447
+
448
+ # --- Address ↔ cst.Module ---
449
+
450
+ @render_func(load_data=load_text)
451
+ def ref_to_cst(input_value, data=None) -> cst.Module:
452
+ """Forward: Address → cst.Module."""
453
+ return None, cst.parse_module(data)
454
+
455
+
456
+ @render_func()
457
+ def save_span_fn(input_value, ref=None):
458
+ """Save handler: write source string back to file span."""
459
+ if not is_editable_source(ref.path):
460
+ print(f"[save_span_fn] refusing to write library source: {ref.path}")
461
+ return None, ref
462
+ full_data = ref.path.read_bytes()
463
+ newline = _detect_newline(full_data)
464
+ try:
465
+ text = full_data.decode("utf-8")
466
+ except UnicodeDecodeError:
467
+ text = full_data.decode("latin-1")
468
+ lines = _split_lines(text)
469
+ new_lines = _split_lines(input_value)
470
+ lines[ref.start:ref.end] = new_lines
471
+ ref.path.write_text(newline.join(lines), encoding="utf-8")
472
+ yellow = "\033[93m"
473
+ reset = "\033[0m"
474
+ print(f"{yellow}[File Write] Updated file {ref.path}{reset}")
475
+ invalidate_usage_cache(ref.path)
476
+ return None, Address(ref.path, ref.start, ref.start + len(new_lines))
477
+
478
+
479
+ @render_func(save_data=save_span_fn)
480
+ def cst_to_ref(input_value):
481
+ """Reverse: cst.Module → source string."""
482
+ return None, input_value.code
483
+
484
+
485
+ # ╔══════════════════════════════════════════════════════════════════════════════╗
486
+ # ║ Instance patching (DictConversion) ║
487
+ # ╚══════════════════════════════════════════════════════════════════════════════╝
488
+
489
+ def _patch_constructor_literals(cls: type, source: str) -> None:
490
+ """Backfill new literal state fields without rerunning live constructors.
491
+
492
+ Only unconditional ``self.field = <literal>`` assignments are safe to
493
+ initialize without constructor arguments or side effects. Existing values,
494
+ including runtime edits and fields with computed initializers, stay intact.
495
+ """
496
+ import copy
497
+ instances = vars(cls).get('_instances')
498
+ if instances is None or not instances:
499
+ return
500
+ node = ast.parse(source)
501
+ for name in cls.__qualname__.split('.'):
502
+ node = next((child for child in node.body
503
+ if isinstance(child, ast.ClassDef) and child.name == name), None)
504
+ if node is None:
505
+ return
506
+ init = next((child for child in node.body
507
+ if isinstance(child, ast.FunctionDef) and child.name == '__init__'), None)
508
+ if init is None or not init.args.args:
509
+ return
510
+ self_name = init.args.args[0].arg
511
+ for statement in init.body:
512
+ if isinstance(statement, ast.Assign):
513
+ targets, value = statement.targets, statement.value
514
+ elif isinstance(statement, ast.AnnAssign):
515
+ targets, value = [statement.target], statement.value
516
+ else:
517
+ continue
518
+ try:
519
+ default = ast.literal_eval(value)
520
+ except (ValueError, TypeError, SyntaxError):
521
+ continue
522
+ for target in targets:
523
+ if (isinstance(target, ast.Attribute) and isinstance(target.value, ast.Name)
524
+ and target.value.id == self_name):
525
+ for instance in list(instances):
526
+ if target.attr not in vars(instance):
527
+ setattr(instance, target.attr, copy.deepcopy(default))
528
+
529
+
530
+ def _patch_instances(cls: type) -> None:
531
+ """Add new field defaults to live instances after a class hotswap.
532
+
533
+ Only touches DictConversion subclasses that track instances via
534
+ _instances WeakSet. Adds attributes introduced by the edit and
535
+ removes attributes deleted from the class — existing values that
536
+ the user set are never overwritten.
537
+ """
538
+ instances = getattr(cls, '_instances', None)
539
+ if instances is None:
540
+ return
541
+
542
+ new_defaults = getattr(cls, '__field_defaults__', {})
543
+ for inst in list(instances):
544
+ # Add new fields the instance doesn't have yet
545
+ for key, default in new_defaults.items():
546
+ if key not in inst.__dict__:
547
+ setattr(inst, key, default)
548
+
549
+ # Remove instance attrs that are no longer class fields
550
+ for key in list(inst.__dict__):
551
+ if key.startswith('_'):
552
+ continue
553
+ if (key not in new_defaults
554
+ and key not in ('id', 'hash', 'name', 'tint')
555
+ and not hasattr(cls, key)):
556
+ try:
557
+ delattr(inst, key)
558
+ except AttributeError:
559
+ pass
560
+
561
+
562
+ # ╔══════════════════════════════════════════════════════════════════════════════╗
563
+ # ║ Internal : recompilation / hotswap ║
564
+ # ╚══════════════════════════════════════════════════════════════════════════════╝
565
+
566
+ _BUILTIN_NAMES = set(dir(builtins))
567
+
568
+
569
+ def _backfill_declared_imports(ns, filename) -> bool:
570
+ """Exec into `ns` every module-scope import the file's CURRENT text
571
+ (pending-inclusive) declares whose bound name is missing there. True when
572
+ anything was added.
573
+
574
+ Heals a STALE MODULE TWIN: the same file lives in sys.modules under two
575
+ identities (src./non-src — see the dual-identity memory), and a twin
576
+ imported before an import line was added to the file never re-runs it.
577
+ Re-exec'ing a span in that twin's globals then NameErrors on the newer
578
+ name at its decorator/default line (`@defaults(...)` was the hunt).
579
+ Import statements only, missing names only — same live-heal the
580
+ auto-import quick-fix applies."""
581
+ import ast
582
+ try:
583
+ from meltygui.editor.pending_save import PendingSave
584
+ text = PendingSave.current_file_text(Path(filename))
585
+ if text is None:
586
+ with open(filename, encoding="utf-8", errors="replace") as f:
587
+ text = f.read()
588
+ tree = ast.parse(text)
589
+ except Exception:
590
+ return False
591
+ added = False
592
+ for node in tree.body:
593
+ if not isinstance(node, (ast.Import, ast.ImportFrom)):
594
+ continue
595
+ bound = [a.asname or (a.name.partition(".")[0]
596
+ if isinstance(node, ast.Import) else a.name)
597
+ for a in node.names if a.name != "*"]
598
+ if not bound or all(b in ns for b in bound):
599
+ continue
600
+ seg = ast.get_source_segment(text, node)
601
+ if not seg:
602
+ continue
603
+ try:
604
+ exec(compile(textwrap.dedent(seg), str(filename), "exec"), ns)
605
+ added = True
606
+ except Exception:
607
+ continue # a failing import should never break recompile
608
+ return added
609
+
610
+
611
+ def _validate_global_names(code, namespace: dict) -> None:
612
+ """Check LOAD_GLOBAL names against namespace + builtins before hotswap."""
613
+ for instr in dis.get_instructions(code):
614
+ if instr.opname in ("LOAD_GLOBAL", "LOAD_NAME"):
615
+ name = instr.argval
616
+ if name not in namespace and name not in _BUILTIN_NAMES:
617
+ raise NameError(f"name '{name}' is not defined")
618
+ for const in code.co_consts:
619
+ if isinstance(const, types.CodeType):
620
+ _validate_global_names(const, namespace)
621
+
622
+
623
+ def _validate_local_names(code) -> None:
624
+ """Check for local variables loaded before being stored."""
625
+ n_params = code.co_argcount + code.co_kwonlyargcount
626
+ if hasattr(code, 'co_posonlyargcount'):
627
+ n_params += code.co_posonlyargcount
628
+ param_names = set(code.co_varnames[:n_params])
629
+
630
+ first_ref: dict[str, str] = {}
631
+ for instr in dis.get_instructions(code):
632
+ if instr.opname in ("STORE_FAST", "STORE_NAME"):
633
+ if instr.argval not in first_ref:
634
+ first_ref[instr.argval] = "store"
635
+ elif instr.opname in ("LOAD_FAST", "LOAD_FAST_CHECK",
636
+ "LOAD_FAST_AND_CLEAR"):
637
+ if instr.argval not in first_ref:
638
+ first_ref[instr.argval] = "load"
639
+
640
+ # for name, ref_type in first_ref.items():
641
+ # if name not in param_names and ref_type == "load":
642
+ # raise UnboundLocalError(
643
+ # f"cannot access local variable '{name}' where it is "
644
+ # f"not associated with a value"
645
+ # )
646
+
647
+ for const in code.co_consts:
648
+ if isinstance(const, types.CodeType):
649
+ _validate_local_names(const)
650
+
651
+
652
+ @lag_traced("recompile fn (hotswap)", 50)
653
+ def _recompile(func: types.FunctionType, source: str,
654
+ filename: str) -> None:
655
+ dedented = textwrap.dedent(source)
656
+ dedented = "\n" * (func.__code__.co_firstlineno - 1) + dedented
657
+ unwrapped = inspect.unwrap(func)
658
+ namespace = dict(unwrapped.__globals__)
659
+
660
+ # Snapshot the decorator registries BEFORE exec re-runs the decorators (which
661
+ # register a throwaway wrapper), restored after the hotswap below.
662
+ _pre_reg = _snapshot_func_registrations()
663
+
664
+ freevars = unwrapped.__code__.co_freevars
665
+ has_closure = bool(freevars and unwrapped.__closure__)
666
+
667
+ if has_closure:
668
+ closure_vals = {}
669
+ for name, cell in zip(freevars, unwrapped.__closure__):
670
+ try:
671
+ closure_vals[name] = cell.cell_contents
672
+ except ValueError:
673
+ closure_vals[name] = None
674
+
675
+ param_list = ", ".join(freevars)
676
+ wrapper_source = f"def _closure_wrapper({param_list}):\n"
677
+ wrapper_source += textwrap.indent(dedented, " ")
678
+ wrapper_source += f"\n return {unwrapped.__name__}\n"
679
+
680
+ code = compile(wrapper_source, filename, "exec")
681
+ else:
682
+ code = compile(dedented, filename, "exec")
683
+
684
+ def _exec_new():
685
+ # annotation scope: a default arg / annotation that CALLS a render func
686
+ # must return its carrier, not render on this (non-GL) thread.
687
+ with Melty.annotation_scope():
688
+ exec(code, namespace)
689
+ if has_closure:
690
+ return namespace["_closure_wrapper"](**closure_vals)
691
+ return namespace.get(unwrapped.__name__)
692
+
693
+ try:
694
+ new_func = _exec_new()
695
+ except NameError:
696
+ # A stale module twin's globals may miss an import the file's editor
697
+ # text declares (the re-run decorator/default is what trips it) -
698
+ # backfill the REAL module namespace and retry once, so the hotswapped
699
+ # body resolves the imports at runtime too.
700
+ if not _backfill_declared_imports(unwrapped.__globals__, filename):
701
+ raise
702
+ for k, v in unwrapped.__globals__.items():
703
+ namespace.setdefault(k, v)
704
+ new_func = _exec_new()
705
+
706
+ if new_func is None:
707
+ print_stack_trace()
708
+
709
+ if not callable(new_func):
710
+ print_stack_trace()
711
+ return TypeError(f"Recompiled object '{unwrapped.__name__}' is not callable")
712
+
713
+ # Capture the freshly-DECORATED object that exec produced: re-running
714
+ # @render_func/@window/etc. registered THIS throwaway object in Melty's
715
+ # registries. We hotswap the original file function in place (below) - keeping
716
+ # `from x import fn` refs to the original wrapper valid - then restore the
717
+ # registries to that original wrapper (see _redirect_function_registrations).
718
+ # Left registered, the throwaway causes stale renders and windows on entering
719
+ # vars(module): its co_firstlineno rots → resolve_address returns start=0.
720
+ new_wrapper = new_func
721
+ new_func = inspect.unwrap(new_func)
722
+
723
+ try:
724
+ _validate_global_names(new_func.__code__, namespace)
725
+ _validate_local_names(new_func.__code__)
726
+
727
+ original_firstlineno = unwrapped.__code__.co_firstlineno
728
+
729
+ # Snapshot the PREVIOUS compiled state before patching, so the hotswap
730
+ # guard can roll it back if the new code throws at runtime (see register).
731
+ _prev = (unwrapped.__code__, unwrapped.__defaults__,
732
+ unwrapped.__kwdefaults__, dict(unwrapped.__annotations__ or {}),
733
+ unwrapped.__doc__)
734
+
735
+ unwrapped.__code__ = new_func.__code__
736
+ unwrapped.__defaults__ = new_func.__defaults__
737
+ unwrapped.__kwdefaults__ = new_func.__kwdefaults__
738
+ unwrapped.__annotations__ = new_func.__annotations__
739
+ unwrapped.__doc__ = new_func.__doc__
740
+
741
+ unwrapped.__code__ = unwrapped.__code__.replace(
742
+ co_firstlineno=original_firstlineno
743
+ )
744
+
745
+ # Mirror the patch onto the file's other module identity (src./non-src
746
+ # twin), so registry-resolved callers (RenderFuncs.<name>) update no
747
+ # matter which twin's raw the editor resolved.
748
+ _twins = _patch_twin_raws(unwrapped)
749
+
750
+ _live_wrapper = _redirect_function_registrations(
751
+ _pre_reg, new_wrapper, live_raw=unwrapped, live_func=func,
752
+ new_raw=new_func)
753
+ # Re-run decoration effects (@defaults) onto the live function: the exec
754
+ # registered them under the throwaway exec produced, so copy them across
755
+ # or the edited decoration will never reach the function the app renders.
756
+ # Key on the resolved LIVE wrapper (the module-global the app calls),
757
+ # not `func` - the editor may hand _recompile the raw, and an entry
758
+ # left under the stale wrapper key would shadow the fresh one.
759
+ _redirect_function_decorations(new_wrapper, new_func,
760
+ _live_wrapper or func, unwrapped)
761
+
762
+ # And carry the freshly-evaluated @render_func decoration state (closure
763
+ # config + instance attrs) onto the live wrapper, so editing a decorator
764
+ # line takes effect without rebinding anything (see _transfer_wrapper_state).
765
+ if new_wrapper is not None and getattr(new_wrapper, "__render_func__", False):
766
+ _lw = unwrapped.__globals__.get(unwrapped.__name__)
767
+ if not (callable(_lw) and _lw is not new_wrapper
768
+ and getattr(_lw, "__render_func__", False)
769
+ and inspect.unwrap(_lw) is unwrapped):
770
+ _lw = func if (func is not new_wrapper
771
+ and getattr(func, "__render_func__", False)) else None
772
+ if _lw is not None:
773
+ _transfer_wrapper_state(_lw, new_wrapper, new_func)
774
+
775
+ def _restore(u=unwrapped, prev=_prev, twins=tuple(_twins)):
776
+ u.__code__, u.__defaults__, u.__kwdefaults__, ann, u.__doc__ = prev
777
+ u.__annotations__ = dict(ann)
778
+ _restore_twin_raws(twins)
779
+ Melty.cache.invalidate_up_by_func(u, max_depth=10)
780
+ # The installed code's co_firstlineno was reset to the function's file
781
+ # position, so a traceback's line number is file-absolute → subtract
782
+ # (firstlineno - 1) to map back to the editor buffer (1-based).
783
+ _register_hotswap(func, _restore, unwrapped.__code__,
784
+ line_base=original_firstlineno - 1)
785
+
786
+ except Exception as e:
787
+ print_stack_trace(exception=e)
788
+ return e
789
+
790
+ Melty.cache.invalidate_up_by_func(func, max_depth=10)
791
+
792
+
793
+ def _exec_file_imports(filename: str, namespace: dict) -> None:
794
+ """Best-effort: exec the file's top-level import lines into `namespace`.
795
+
796
+ Used to resolve a name (e.g. a freshly-inserted `@defaults` import) that the
797
+ running module's globals don't have yet. Each import is exec'd in isolation;
798
+ failures are ignored (relative/conditional imports may not stand alone)."""
799
+ try:
800
+ text = Path(filename).read_text(encoding="utf-8")
801
+ except OSError:
802
+ return
803
+ for ln in text.splitlines():
804
+ s = ln.strip()
805
+ if s.startswith("import ") or s.startswith("from "):
806
+ try:
807
+ exec(s, namespace)
808
+ except Exception:
809
+ pass
810
+
811
+
812
+ @lag_traced("recompile class (hotswap)", 50)
813
+ def _recompile_class(cls: type, source: str, filename: str) -> None:
814
+ import sys
815
+ dedented = textwrap.dedent(source)
816
+
817
+ mod = sys.modules.get(cls.__module__)
818
+ namespace = dict(vars(mod)) if mod is not None else {}
819
+
820
+ try:
821
+ code = compile(dedented, filename, "exec")
822
+ except SyntaxError as syntax_e:
823
+ return syntax_e
824
+
825
+ try:
826
+ # annotation_scope: the class body re-evaluates field annotations that
827
+ # CALL render funcs (`tint: draw_any(...)`) - they must return carriers
828
+ # (annotation_track), not render on this background thread (no GL
829
+ # context → FBO failure + imgui ID-stack corruption on the render
830
+ # thread). Thread-local, so live rendering elsewhere is untouched.
831
+ with Melty.annotation_scope():
832
+ exec(code, namespace)
833
+ except NameError:
834
+ try:
835
+ # A just-inserted import (e.g. the @defaults decorator) isn't in the live
836
+ # module globals yet. Pull in the file's imports and retry once.
837
+ with Melty.annotation_scope():
838
+ _exec_file_imports(filename, namespace)
839
+ exec(code, namespace)
840
+ except Exception as e:
841
+ return e
842
+
843
+ new_cls = namespace.get(cls.__name__)
844
+ if new_cls is None:
845
+ return NameError(f"Class '{cls.__name__}' not found in recompiled code")
846
+
847
+ # Snapshot the PREVIOUS compiled state before patching: a throwaway clone whose
848
+ # members (incl. live method code objects) mirror the current class. Rollback
849
+ # re-hotswaps this clone back over cls, keeping methods/attrs in place.
850
+ _prev_cls = _snapshot_class(cls)
851
+
852
+ _hotswap_class(cls, new_cls, src_map=_attr_source_map(dedented), qualname=cls.__name__)
853
+ _redirect_class_registrations(cls, new_cls)
854
+ Melty.cache.invalidate_up_by_obj(cls, max_depth=10)
855
+
856
+ def _restore(c=cls, snap=_prev_cls):
857
+ _hotswap_class(c, snap, force=True)
858
+ _redirect_class_registrations(c, snap)
859
+ Melty.cache.invalidate_up_by_obj(c, max_depth=10)
860
+ # Class bodies compile at buffer-relative line numbers (the editor shows the
861
+ # whole class span starting at line 1), so no line base offset.
862
+ _register_hotswap(cls, _restore, _class_code_objects(new_cls), line_base=0)
863
+ return None
864
+
865
+
866
+ def module_for_path(path):
867
+ """The live module loaded from `path` (resolved), or None.
868
+
869
+ Whole-file editors resolve through TextFileCodec, whose Address carries the
870
+ PATH as `source` — there is no module object to dispatch on until hotswap
871
+ time, so recompile callers resolve it here (same scan as the MCP server's
872
+ hotswap_file)."""
873
+ import sys
874
+ try:
875
+ target = Path(path).resolve()
876
+ except (OSError, ValueError):
877
+ return None
878
+ for mod in list(sys.modules.values()):
879
+ f = getattr(mod, "__file__", None)
880
+ if not f:
881
+ continue
882
+ try:
883
+ if Path(f).resolve() == target:
884
+ return mod
885
+ except (OSError, ValueError):
886
+ continue
887
+ return None
888
+
889
+
890
+ @lag_traced("recompile module (hotswap)", 50)
891
+ def _recompile_module(module: types.ModuleType, source: str,
892
+ filename: str) -> None:
893
+ old_attrs = dict(module.__dict__)
894
+
895
+ code = compile(source, filename, "exec")
896
+ # The exec below re-runs every decorator in the module, registering throwaway
897
+ # wrappers in Melty's registries (same problem as _recompile). Snapshot the
898
+ # registries first so each function's registrations can be reconciled back to
899
+ # its live wrapper after patching.
900
+ _pre_reg = _snapshot_func_registrations()
901
+ # Per-member snapshot of the PREVIOUS compiled state, taken BEFORE patching so
902
+ # in-place edits don't clobber it (old_attrs aliases the live objects, whose
903
+ # __code__ we update below). Each entry is a zero-arg restore closure.
904
+ _member_restores = []
905
+ _swapped_funcs = []
906
+ _swapped_classes = []
907
+ new_code_ids = set()
908
+ src_map = _attr_source_map(source)
909
+ module_baseline = old_attrs.get("__hotswap_attr_src__")
910
+ live_by_name = {}
911
+ try:
912
+ # annotation_scope: module bodies hold @window classes whose field
913
+ # annotations CALL render funcs - same interception _recompile_class
914
+ # needs, thread-local so live rendering is untouched.
915
+ with Melty.annotation_scope():
916
+ exec(code, module.__dict__)
917
+
918
+ new_attrs = dict(module.__dict__)
919
+ replacements = {
920
+ id(new_attrs[name]): (new_attrs[name], old)
921
+ for name, old in old_attrs.items()
922
+ if name in new_attrs and new_attrs[name] is not old
923
+ and ((isinstance(old, types.FunctionType) and isinstance(new_attrs[name], types.FunctionType))
924
+ or (isinstance(old, type) and isinstance(new_attrs[name], type)))
925
+ }
926
+ # Enum members are also identity-bearing definitions. Module tuples
927
+ # such as LEFT_ANCHORS are evaluated with the throwaway enum during
928
+ # exec; rebinding only the class leaves live window anchors outside
929
+ # every classification tuple after a state-module hotswap.
930
+ for new_definition, live_definition in list(replacements.values()):
931
+ if isinstance(new_definition, EnumMeta) and isinstance(live_definition, EnumMeta):
932
+ for member_name, member in new_definition.__members__.items():
933
+ live_member = live_definition.__members__.get(member_name)
934
+ if live_member is not None:
935
+ replacements[id(member)] = (member, live_member)
936
+ canonicalize_definitions(replacements)
937
+
938
+ for name, old_obj in old_attrs.items():
939
+ new_obj = new_attrs.get(name)
940
+ if new_obj is old_obj or new_obj is None:
941
+ continue
942
+
943
+ if isinstance(old_obj, types.FunctionType) and isinstance(new_obj, types.FunctionType):
944
+ # Patch the RAW function. For decorated functions (@render_func,
945
+ # @wraps) old/new are both generic wrapper closures sharing the
946
+ # same wrapper code object - copying that __code__ is a no-op and
947
+ # the old wrapper keeps calling its OLD inner via its closure
948
+ # cell. The behavior lives in the inner raw, so patch that.
949
+ old_raw = inspect.unwrap(old_obj)
950
+ new_raw = inspect.unwrap(new_obj)
951
+ both_wrapped = old_raw is not old_obj and new_raw is not new_obj
952
+ tgt, src = (old_raw, new_raw) if both_wrapped else (old_obj, new_obj)
953
+
954
+ _member_restores.append(patch_function(tgt, src))
955
+ old_obj.__module__ = new_obj.__module__
956
+ old_obj.__qualname__ = new_obj.__qualname__
957
+ module.__dict__[name] = old_obj
958
+ # Mirror onto the function's other module identity (src./non-src
959
+ # twin) so registry-resolved addresses update too.
960
+ _twins = [] if tgt.__dict__.get("__melty_relocated__") else _patch_twin_raws(tgt)
961
+ if _twins:
962
+ def _restore_twins_fn(t=tuple(_twins)):
963
+ _restore_twin_raws(t)
964
+ _member_restores.append(_restore_twins_fn)
965
+ new_code_ids |= _hotswap_guard.collect_code_ids(src.__code__)
966
+
967
+ # Reconcile the throwaway wrapper's registrations and decorator
968
+ # state back onto the live objects - the same contract as
969
+ # _recompile. Without this, render_funcs_by_name (and friends)
970
+ # point at the throwaway while draw_state._view_func and the
971
+ # module global keep executing the live one; whichever side a
972
+ # later edit reaches, the other freezes.
973
+ _redirect_function_registrations(_pre_reg, new_obj,
974
+ live_raw=old_raw, live_func=old_obj,
975
+ new_raw=new_raw)
976
+ _redirect_function_decorations(new_obj, new_raw, old_obj, old_raw)
977
+ if both_wrapped and getattr(new_obj, "__render_func__", False):
978
+ _transfer_wrapper_state(old_obj, new_obj, new_raw)
979
+ # The reload shifts line numbers; a cached Address would make the
980
+ # editor's next span-resolved save splice at stale offsets
981
+ # (symptom: the function tail duplicated on every save).
982
+ invalidate_address_cache(old_obj)
983
+ if both_wrapped:
984
+ invalidate_address_cache(old_raw)
985
+ _swapped_funcs.append(old_obj)
986
+ live_by_name[name] = old_obj
987
+
988
+ elif isinstance(old_obj, type) and isinstance(new_obj, type):
989
+ _snap = _snapshot_class(old_obj)
990
+
991
+ def _restore_cls(o=old_obj, s=_snap):
992
+ _hotswap_class(o, s, force=True)
993
+ _redirect_class_registrations(o, s)
994
+ invalidate_address_cache(o)
995
+ _member_restores.append(_restore_cls)
996
+
997
+ # Rebind the module attr to the LIVE class FIRST. The exec left
998
+ # the throwaway bound there, and the patch below can run code
999
+ # that accesses the that state - a Modes._LazyMode resolving
1000
+ # `Mode[...]` during the enum reconcile latched a member of the
1001
+ # throwaway and cached it forever (the mode edit then applied
1002
+ # everywhere EXCEPT views holding a lazy handle). old_obj is
1003
+ # what the binding ends up as regardless, so moving it up is
1004
+ # free and closes the window for every hotswappable class.
1005
+ module.__dict__[name] = old_obj
1006
+ class_source_map = src_map
1007
+ if new_obj.__module__ != module.__name__:
1008
+ import sys
1009
+ owner = sys.modules.get(new_obj.__module__)
1010
+ owner_path = vars(owner).get("__file__") if owner is not None else None
1011
+ if owner_path is not None:
1012
+ class_source_map = _attr_source_map(Path(owner_path).read_text())
1013
+ _hotswap_class(old_obj, new_obj, src_map=class_source_map, qualname=new_obj.__qualname__)
1014
+ _redirect_class_registrations(old_obj, new_obj)
1015
+ invalidate_address_cache(old_obj)
1016
+ new_code_ids |= _class_code_objects(new_obj)
1017
+ _swapped_classes.append(old_obj)
1018
+ live_by_name[name] = old_obj
1019
+
1020
+ elif (not (name.startswith("__") and name.endswith("__"))
1021
+ and not isinstance(old_obj, (types.FunctionType, type, types.ModuleType))
1022
+ and not isinstance(new_obj, (types.FunctionType, type, types.ModuleType))):
1023
+ # Module-level data binding (`registry = {}`, `host = RenderHost(...)`,
1024
+ # `event_handler = EventHandler()`): same rule as class attrs -
1025
+ # an unchanged source expression keeps the live object.
1026
+ if _keep_live_attr(module_baseline, name, old_obj, new_obj, src_map.get("")):
1027
+ module.__dict__[name] = old_obj
1028
+
1029
+ _stamp_attr_src(module, src_map.get("", {}))
1030
+ # Canonicalize immutable module constants too. The general definition
1031
+ # pass visits exports and function metadata, deliberately not arbitrary
1032
+ # runtime containers; classification tuples need this narrow pass.
1033
+ def live_constant(value):
1034
+ replacement = replacements.get(id(value))
1035
+ if replacement is not None and replacement[0] is value:
1036
+ return replacement[1]
1037
+ if isinstance(value, tuple):
1038
+ items = tuple(live_constant(item) for item in value)
1039
+ if any(old is not new for old, new in zip(value, items)):
1040
+ return items
1041
+ return value
1042
+
1043
+ for name, value in tuple(module.__dict__.items()):
1044
+ if isinstance(value, tuple):
1045
+ module.__dict__[name] = live_constant(value)
1046
+ _repoint_attribute_bindings(module, source, live_by_name)
1047
+ except Exception as e:
1048
+ # Roll back to old attributes on error
1049
+ module.__dict__.update(old_attrs)
1050
+ print(f"Error recompiling module '{module.__name__}': {e}")
1051
+ return e
1052
+
1053
+ # Register a whole-module rollback: re-applying every member restore reverts
1054
+ # the module to its last compiled state if any patched member throws at
1055
+ # runtime. Module bodies compile at file/buffer line numbers → base 0.
1056
+ def _restore_module(restores=tuple(_member_restores)):
1057
+ for r in restores:
1058
+ try:
1059
+ r()
1060
+ except Exception as ex:
1061
+ print(f"[hotswap_guard] module member restore failed: {ex}")
1062
+ _hotswap_guard.register(module, _restore_module, new_code_ids, line_base=0)
1063
+
1064
+ # Repaint every view affected by the swapped functions - cached tiles keep
1065
+ # blitting old pixels (and old code) until something invalidates them.
1066
+ for fn in _swapped_funcs:
1067
+ Melty.cache.invalidate_up_by_func(fn, max_depth=10)
1068
+ # Same for class-driven views - the class-span route (_recompile_class)
1069
+ # invalidates by object; without this a whole-file swap leaves views
1070
+ # reading class attrs (Toggles etc.) blitting stale tiles.
1071
+ for c in _swapped_classes:
1072
+ _patch_constructor_literals(c, source)
1073
+ Melty.cache.invalidate_up_by_obj(c, max_depth=10)
1074
+
1075
+
1076
+ # An enum's member bookkeeping. These are ordinary (non-dunder) class attrs, so
1077
+ # the class attribute loop happily setattrs the THROWAWAY class's version over
1078
+ # them - this silently repoints `_member_map_` at the new member objects while
1079
+ # the class dict keeps the old ones (EnumMeta refuses to reassign a member).
1080
+ # `Mode.TEXT` then resolves to the stale member forever. _reconcile_enum_members
1081
+ # owns these; the loop must leave them alone.
1082
+ _ENUM_INTERNALS = frozenset({
1083
+ "_member_map_", "_member_names_", "_value2member_map_",
1084
+ "_unhashable_values_", "_member_type_", "_value_repr_",
1085
+ })
1086
+
1087
+
1088
+
1089
+ # ---------------------------------------------------------------------------
1090
+ # Hotswap state preservation.
1091
+ #
1092
+ # Hotswap applies SOURCE edits and preserves RUNTIME state. A class body (or a
1093
+ # module body) re-executed by a hotswap yields every data attribute's INITIAL
1094
+ # value again; copying those over the live class is exactly what wiped
1095
+ # `Melty.cache` to None on a meltygui.py swap (class-as-singleton: all its
1096
+ # state is class attributes) and emptied `PendingSave.pending_saves`. The rule
1097
+ # that separates an edit from runtime drift is the attribute's SOURCE
1098
+ # EXPRESSION: unchanged text → keep the live value; changed text → apply the
1099
+ # new one. The previous compile's expressions are the baseline, stamped as
1100
+ # `__hotswap_attr_src__` on each class / module by `stamp_hotswap_baselines`
1101
+ # at boot (latent_descent.main, background thread) and refreshed after every
1102
+ # swap. Without a baseline (a module imported after the boot stamp, first
1103
+ # swap) a compiled None / empty container over a populated live value is
1104
+ # taken as runtime-filled state and kept; everything else applies.
1105
+ # ---------------------------------------------------------------------------
1106
+
1107
+ def _norm_expr(text):
1108
+ return "".join(text.split()) if isinstance(text, str) else text
1109
+
1110
+
1111
+ def _segment(lines, node):
1112
+ """ast.get_source_segment's byte-offset slicing over a PRE-SPLIT line
1113
+ list. The stdlib helper re-splits the ENTIRE source on every call
1114
+ (`_splitlines_no_ff`), making the boot baseline pass O(assignments ×
1115
+ file size) — measured as seconds of GIL-held CPU on the
1116
+ hotswap-baselines thread, convoying the render thread's per-GL-call
1117
+ GIL reacquisitions into the post-boot 1000ms frame burst."""
1118
+ try:
1119
+ if node.end_lineno is None or node.end_col_offset is None:
1120
+ return None
1121
+ lineno = node.lineno - 1
1122
+ end_lineno = node.end_lineno - 1
1123
+ col_offset = node.col_offset
1124
+ end_col_offset = node.end_col_offset
1125
+ except AttributeError:
1126
+ return None
1127
+ if end_lineno == lineno:
1128
+ return lines[lineno].encode()[col_offset:end_col_offset].decode()
1129
+ first = lines[lineno].encode()[col_offset:].decode()
1130
+ last = lines[end_lineno].encode()[:end_col_offset].decode()
1131
+ return "".join([first, *lines[lineno + 1:end_lineno], last])
1132
+
1133
+
1134
+ def _attr_source_map(source: str) -> dict:
1135
+ """{qualname: {attr: expr_text}} for the module body ("" key) and every
1136
+ class body in `source` (nested classes under their dotted qualname). Only
1137
+ direct body assignments count; classes inside functions are skipped.
1138
+ Expression text is whitespace-normalized so a dedented class span and the
1139
+ whole module compare equal."""
1140
+ try:
1141
+ tree = ast.parse(source)
1142
+ except SyntaxError:
1143
+ return {}
1144
+ try:
1145
+ lines = ast._splitlines_no_ff(source) # exact get_source_segment lines
1146
+ except AttributeError: # private helper moved/renamed
1147
+ lines = source.splitlines(keepends=True)
1148
+ out = {}
1149
+
1150
+ def _collect(body, key):
1151
+ m = out.setdefault(key, {})
1152
+ for node in body:
1153
+ if isinstance(node, ast.Assign):
1154
+ seg = _norm_expr(_segment(lines, node.value))
1155
+ for target in node.targets:
1156
+ if isinstance(target, ast.Name):
1157
+ m[target.id] = seg
1158
+ elif (isinstance(node, ast.AnnAssign) and node.value is not None
1159
+ and isinstance(node.target, ast.Name)):
1160
+ m[node.target.id] = _norm_expr(_segment(lines, node.value))
1161
+ elif isinstance(node, ast.ClassDef):
1162
+ _collect(node.body, f"{key}.{node.name}" if key else node.name)
1163
+
1164
+ _collect(tree.body, "")
1165
+ return out
1166
+
1167
+
1168
+ def _keep_live_attr(baseline, name, old_val, new_val, attr_src) -> bool:
1169
+ """True when a plain data attribute keeps its LIVE value across a swap:
1170
+ its source expression is unchanged against the baseline, or (no baseline)
1171
+ the compiled value is the empty shape of runtime-populated state."""
1172
+ if attr_src is not None and baseline is not None and name in attr_src and name in baseline:
1173
+ return baseline[name] == attr_src[name]
1174
+ return ((new_val is None or _is_empty_value(new_val))
1175
+ and not (old_val is None or _is_empty_value(old_val)))
1176
+
1177
+
1178
+ def _stamp_attr_src(obj, attrs) -> None:
1179
+ try:
1180
+ if isinstance(obj, types.ModuleType):
1181
+ obj.__dict__["__hotswap_attr_src__"] = dict(attrs)
1182
+ else:
1183
+ type.__setattr__(obj, "__hotswap_attr_src__", dict(attrs))
1184
+ except Exception:
1185
+ pass
1186
+
1187
+
1188
+ def _apply_attr_map(module: types.ModuleType, src_map: dict) -> None:
1189
+ """Stamp a precomputed _attr_source_map onto a module and its classes."""
1190
+ _stamp_attr_src(module, src_map.get("", {}))
1191
+ for qual, attrs in src_map.items():
1192
+ if not qual:
1193
+ continue
1194
+ obj = module
1195
+ for part in qual.split("."):
1196
+ obj = getattr(obj, part, None)
1197
+ if obj is None:
1198
+ break
1199
+ if isinstance(obj, type) and getattr(obj, "__module__", None) == module.__name__:
1200
+ _stamp_attr_src(obj, attrs)
1201
+
1202
+
1203
+ def stamp_module_baseline(module: types.ModuleType, source: str = None) -> None:
1204
+ """Record the current source expressions of the module's data bindings
1205
+ and of every class defined in it (nested included) as the hotswap
1206
+ baseline. `source` defaults to the module's file on disk."""
1207
+ if source is None:
1208
+ f = getattr(module, "__file__", None)
1209
+ if not f:
1210
+ return
1211
+ source = Path(f).read_text(encoding="utf-8")
1212
+ _apply_attr_map(module, _attr_source_map(source))
1213
+
1214
+
1215
+ def stamp_hotswap_baselines(delay: float = 0.0) -> int:
1216
+ """Boot-time baseline stamp for every loaded project module (both the
1217
+ `src.lsd.*` and `lsd.*` identities). Disk reads + ast only, so it runs on
1218
+ a background thread; `delay` lets startup imports land first. Returns the
1219
+ number of modules stamped.
1220
+
1221
+ Dual-identity twins share one read + parse (grouped by __file__), and a
1222
+ short sleep between files keeps this CPU-bound pass from GIL-convoying
1223
+ the render thread's per-GL-call reacquisitions — this thread was the
1224
+ invisible source of the post-boot 1000ms frame burst (stall watchdog,
1225
+ 08-24), amplified by get_source_segment's quadratic re-split (fixed in
1226
+ _segment)."""
1227
+ import sys as _sys
1228
+ from meltygui.code.fileref import is_editable_source
1229
+ from meltygui.core.diagnostics.perf_trace import span as _pt_span
1230
+ if delay:
1231
+ time.sleep(delay)
1232
+ by_file = {}
1233
+ for mod in list(_sys.modules.values()):
1234
+ f = getattr(mod, "__file__", None)
1235
+ if not f or not f.endswith(".py") or not is_editable_source(f):
1236
+ continue
1237
+ by_file.setdefault(f, []).append(mod)
1238
+ n = 0
1239
+ with _pt_span("hotswap baselines stamp", files=len(by_file)):
1240
+ for f, mods in by_file.items():
1241
+ try:
1242
+ source = Path(f).read_text(encoding="utf-8")
1243
+ src_map = _attr_source_map(source)
1244
+ except Exception:
1245
+ continue
1246
+ for mod in mods:
1247
+ try:
1248
+ _apply_attr_map(mod, src_map)
1249
+ n += 1
1250
+ except Exception:
1251
+ continue
1252
+ time.sleep(0.002)
1253
+ return n
1254
+
1255
+
1256
+ def _repoint_attribute_bindings(module: types.ModuleType, source: str, live_by_name: dict) -> None:
1257
+ """Module-level `holder.attr = Name` statements re-ran during the exec and
1258
+ bound the THROWAWAY object (meltygui.py: `Core.melty = Melty`). Re-point each
1259
+ at the live object the module dict holds for that name."""
1260
+ if not live_by_name:
1261
+ return
1262
+ try:
1263
+ tree = ast.parse(source)
1264
+ except SyntaxError:
1265
+ return
1266
+ for node in tree.body:
1267
+ if isinstance(node, ast.Assign):
1268
+ targets, value = node.targets, node.value
1269
+ elif isinstance(node, ast.AnnAssign) and node.value is not None:
1270
+ targets, value = [node.target], node.value
1271
+ else:
1272
+ continue
1273
+ if not isinstance(value, ast.Name) or value.id not in live_by_name:
1274
+ continue
1275
+ live = live_by_name[value.id]
1276
+ for target in targets:
1277
+ if not isinstance(target, ast.Attribute):
1278
+ continue
1279
+ try:
1280
+ holder = eval(compile(ast.Expression(target.value), "<hotswap>", "eval"),
1281
+ module.__dict__)
1282
+ if getattr(holder, target.attr, None) is not live:
1283
+ setattr(holder, target.attr, live)
1284
+ except Exception:
1285
+ continue
1286
+
1287
+
1288
+ def _hotswap_class(old_cls: type, new_cls: type, src_map: dict = None,
1289
+ qualname: str = None, force: bool = False) -> None:
1290
+ """Patch an existing class in place with new methods and attributes.
1291
+
1292
+ Plain data attributes follow the state-preservation rule above
1293
+ (`_keep_live_attr`, keyed by `src_map[qualname]`); `force=True` (rollback
1294
+ to a snapshot) writes every member unconditionally. Nested classes are
1295
+ patched recursively so their identity survives too."""
1296
+ attr_src = src_map.get(qualname) if (src_map and qualname) else None
1297
+ baseline = vars(old_cls).get("__hotswap_attr_src__") if not force else None
1298
+ _is_enum = isinstance(old_cls, EnumMeta)
1299
+ # NOTE: do NOT invalidate the address cache here. The caller
1300
+ # (recompile_cls_fn) handles cache updates via update_address_cache.
1301
+ # Invalidating here creates a race window where a concurrent
1302
+ # background convert-in chain calls to_address, misses the cache,
1303
+ # and re-caches the OLD range from inspect.getsourcelines.
1304
+
1305
+ for name in list(vars(old_cls)):
1306
+ if name.startswith("__") and name.endswith("__"):
1307
+ continue
1308
+ if isinstance(vars(old_cls).get(name), types.MemberDescriptorType):
1309
+ # Slot descriptor: deleting it or removing the slot breaks every live
1310
+ # instance (AttributeError on access) - the descriptor can't be
1311
+ # removed in place anyway.
1312
+ continue
1313
+ if name not in vars(new_cls):
1314
+ try:
1315
+ delattr(old_cls, name)
1316
+ except AttributeError:
1317
+ pass
1318
+
1319
+ for name, new_val in vars(new_cls).items():
1320
+ # __class__ is the metaclass slot, not a reconcilable member - a class
1321
+ # that defines `__class__` as a property (transparent-proxy pattern,
1322
+ # e.g. _LazyMode) puts it in vars(); setattr(old_cls, '__class__', prop)
1323
+ # then raises "must be set to a class". Never patch it in place.
1324
+ if name in ("__dict__", "__weakref__", "_instances", "__class__"):
1325
+ continue
1326
+ if _is_enum and name in _ENUM_INTERNALS:
1327
+ continue
1328
+
1329
+ old_val = vars(old_cls).get(name)
1330
+
1331
+ if (isinstance(new_val, types.MemberDescriptorType)
1332
+ or isinstance(old_val, types.MemberDescriptorType)):
1333
+ # Slot descriptors are tied to their defining class. Copying the
1334
+ # new class's onto the old one makes EVERY slot access on existing
1335
+ # instances raise "descriptor doesn't apply" (the stale-LiveHandle
1336
+ # crash). The old class keeps its own descriptors - the slot
1337
+ # layout can't change in place.
1338
+ continue
1339
+
1340
+ if (isinstance(old_val, types.FunctionType)
1341
+ and isinstance(new_val, types.FunctionType)):
1342
+ if not (old_val.__qualname__.startswith(old_cls.__qualname__ + ".")
1343
+ and new_val.__qualname__.startswith(new_cls.__qualname__ + ".")):
1344
+ # A function-valued attribute (e.g. view_func=draw_text) is
1345
+ # a closure, not a method defined by this class. Rebind it;
1346
+ # patching its code would mutate the shared renderer itself.
1347
+ setattr(old_cls, name, new_val)
1348
+ continue
1349
+ patch_function(old_val, new_val, force=force)
1350
+ elif type(old_val) is staticmethod and type(new_val) is staticmethod:
1351
+ patch_function(old_val.__func__, new_val.__func__, force=force)
1352
+ elif type(old_val) is classmethod and type(new_val) is classmethod:
1353
+ patch_function(old_val.__func__, new_val.__func__, force=force)
1354
+ elif isinstance(new_val, property):
1355
+ try:
1356
+ setattr(old_cls, name, new_val)
1357
+ except (AttributeError, TypeError):
1358
+ pass
1359
+ elif (isinstance(old_val, type) and isinstance(new_val, type)
1360
+ and old_val is not new_val
1361
+ and getattr(new_val, "__qualname__", "").startswith(new_cls.__qualname__ + ".")):
1362
+ # Nested class: patch in place (identity + runtime state survive)
1363
+ # and move its re-run decorator registrations onto the live one.
1364
+ _hotswap_class(old_val, new_val, src_map=src_map,
1365
+ qualname=f"{qualname}.{name}" if qualname else None,
1366
+ force=force)
1367
+ _redirect_class_registrations(old_val, new_val)
1368
+ else:
1369
+ if old_val is new_val:
1370
+ continue
1371
+ if not force and _keep_live_attr(baseline, name, old_val, new_val, attr_src):
1372
+ continue
1373
+ try:
1374
+ setattr(old_cls, name, new_val)
1375
+ except (AttributeError, TypeError):
1376
+ pass
1377
+
1378
+ if attr_src is not None and not force:
1379
+ _stamp_attr_src(old_cls, attr_src)
1380
+
1381
+ # AFTER the attribute loop: an edited enum __init__ (Mode's, which derives
1382
+ # `unwrapped` from the value) is patched above, and the member state copied
1383
+ # below was produced by the NEW body running under it.
1384
+ if isinstance(old_cls, EnumMeta):
1385
+ _reconcile_enum_members(old_cls, new_cls)
1386
+
1387
+
1388
+ def _is_empty_value(value) -> bool:
1389
+ """An empty container — the shape a member has before module-level code
1390
+ populates it (see the class-route guard in _reconcile_enum_members)."""
1391
+ return isinstance(value, (dict, list, tuple, set, frozenset, str)) and not value
1392
+
1393
+
1394
+ def _reconcile_enum_members(old_cls: type, new_cls: type) -> None:
1395
+ """Refresh a live enum's MEMBERS after its class body was recompiled.
1396
+
1397
+ Enum members are shared by identity, not by copy: `Mode.CODE_UI` is ONE
1398
+ object that every view, draw_state kwarg (`current_mode`) and Modes handle
1399
+ holds a reference to. `_hotswap_class`'s normal attribute loop can't touch
1400
+ them — EnumMeta.__setattr__ refuses to reassign a member ("cannot reassign
1401
+ member"), so the setattr silently lands in its except and the recompiled
1402
+ values never reach the app. That's why editing mode.py used to need a
1403
+ restart even though the swap reported success.
1404
+
1405
+ Rather than swap in the throwaway class's members (which would strand every
1406
+ reference already held), the EXISTING member objects are mutated in place:
1407
+ each keeps its identity and simply starts reporting the new `_value_` (plus
1408
+ whatever the enum's `__init__` derived from it — `Mode.unwrapped`). Every
1409
+ holder therefore sees the edit with no per-view update at all.
1410
+
1411
+ Members ADDED by the edit are constructed onto the live class (the enum
1412
+ registries have to be extended by hand — EnumMeta only builds them at class
1413
+ creation). Members REMOVED are left in place: something in the app may still
1414
+ hold one, and a dangling reference is worse than a stale one.
1415
+
1416
+ `new_cls` may also be a `_snapshot_class` clone carrying `_enum_member_state_`
1417
+ — the rollback direction, restoring the pre-swap state the same way.
1418
+ """
1419
+ snapshot = getattr(new_cls, "_enum_member_state_", None)
1420
+ if isinstance(snapshot, dict):
1421
+ new_state = snapshot
1422
+ elif isinstance(new_cls, EnumMeta):
1423
+ new_state = {name: dict(vars(m)) for name, m in new_cls.__members__.items()}
1424
+ else:
1425
+ return
1426
+
1427
+ changed = []
1428
+ added = []
1429
+ for name, state in new_state.items():
1430
+ old_m = old_cls.__members__.get(name)
1431
+ if old_m is None:
1432
+ added.append((name, state))
1433
+ continue
1434
+ # `state` is the full copy of the fresh member's __dict__ (_name_,
1435
+ # _value_, and whatever the enum's __init__ derived - Mode.unwrapped),
1436
+ # so replacing wholesale also drops attrs the new body stopped setting.
1437
+ # EXCEPT when the copied member is empty and the live one isn't: a
1438
+ # CLASS-route recompile (`_recompile_class`) re-runs only the class
1439
+ # BODY, so anything module-level code filled in afterwards is missing.
1440
+ # Mode.CODE is literally `CODE = {}` in the body, populated below the
1441
+ # in by _populate_code_mode() - into `unwrapped`, not the dict - and
1442
+ # a wholesale copy blanks it. Empty-over-nonempty is never a change
1443
+ # worth applying; genuinely emptying a member needs a restart.
1444
+ merged = dict(state)
1445
+ for key, live_val in vars(old_m).items():
1446
+ if _is_empty_value(merged.get(key)) and not _is_empty_value(live_val):
1447
+ merged[key] = live_val
1448
+ if vars(old_m) == merged:
1449
+ continue
1450
+ old_m.__dict__.clear()
1451
+ old_m.__dict__.update(merged)
1452
+ # ...except __objclass__, which the copied state points at the
1453
+ # throwaway class. The member belongs to the LIVE class.
1454
+ old_m.__objclass__ = old_cls
1455
+ changed.append(old_m)
1456
+
1457
+ for name, state in added:
1458
+ try:
1459
+ member = object.__new__(old_cls)
1460
+ member.__dict__.update(state)
1461
+ member._name_ = name
1462
+ member.__objclass__ = old_cls
1463
+ # type.__setattr__ bypasses EnumMeta's "cannot reassign member"
1464
+ # guard, which also blocks the initial ASSIGNMENT of a new one.
1465
+ type.__setattr__(old_cls, name, member)
1466
+ old_cls._member_map_[name] = member
1467
+ if name not in old_cls._member_names_:
1468
+ old_cls._member_names_.append(name)
1469
+ changed.append(member)
1470
+ except Exception as e:
1471
+ print(f"[hotswap] could not add enum member {old_cls.__name__}.{name}: {e}")
1472
+
1473
+ if not changed:
1474
+ return
1475
+ # Value lookup (Mode(value)) indexes members by value at class creation.
1476
+ # Mode's values are dicts - unhashable - so the map only ever holds the
1477
+ # hashable ones and py3.12 keeps the rest in _unhashable_values_ for
1478
+ # _missing_ to scan; rebuild both from the current state.
1479
+ try:
1480
+ old_cls._value2member_map_ = {}
1481
+ unhashable = [] if hasattr(old_cls, "_unhashable_values_") else None
1482
+ for m in old_cls.__members__.values():
1483
+ try:
1484
+ old_cls._value2member_map_[m._value_] = m
1485
+ except TypeError:
1486
+ if unhashable is not None:
1487
+ unhashable.append(m._value_)
1488
+ if unhashable is not None:
1489
+ old_cls._unhashable_values_ = unhashable
1490
+ except Exception as e:
1491
+ print(f"[hotswap] enum value map rebuild failed for {old_cls.__name__}: {e}")
1492
+
1493
+ _invalidate_views_using_members(changed)
1494
+
1495
+
1496
+ def _invalidate_views_using_members(members) -> None:
1497
+ """Repaint the views a just-changed enum member drives.
1498
+
1499
+ The member objects kept their identity, so nothing in the app knows their
1500
+ config moved — cached tiles keep blitting pixels drawn from the OLD mode.
1501
+ The wrapper stamps the active mode onto each view's kwargs
1502
+ (`current_mode`, or `mode` for the recursive variant), so the cache's own
1503
+ draw_state table is the complete list of affected views: one scan, no
1504
+ per-view bookkeeping anywhere else.
1505
+ """
1506
+ cache = getattr(Melty, "cache", None)
1507
+ table = getattr(cache, "key_to_draw_state", None)
1508
+ if not table:
1509
+ return
1510
+ # Enum members hash/compare by identity, and every Modes._LazyMode handle
1511
+ # forwards both to the member it resolves to, so a kwarg holding either
1512
+ # spelling matches.
1513
+ targets = set(members)
1514
+ for ds in list(table.values()):
1515
+ kwargs = getattr(ds, "_kwargs", None)
1516
+ if not kwargs:
1517
+ continue
1518
+ for key in ("current_mode", "mode"):
1519
+ mode = kwargs.get(key)
1520
+ if mode is None:
1521
+ continue
1522
+ try:
1523
+ hit = mode in targets
1524
+ except Exception:
1525
+ hit = False
1526
+ if hit:
1527
+ ds.invalidate_up(max_depth=6)
1528
+ break
1529
+
1530
+
1531
+ def _redirect_class_registrations(old_cls: type, new_cls: type) -> None:
1532
+ """Repoint decorator-driven global registries from `new_cls` back to `old_cls`.
1533
+
1534
+ `_recompile_class` re-execs the class body, which RE-RUNS its decorators
1535
+ (`@window`, `@defaults`, …). Those decorators don't just mutate the class —
1536
+ they register it in module-level registries. So the throwaway `new_cls` ends
1537
+ up registered while the rest of the app still holds the hotswapped `old_cls`:
1538
+ the two diverge, and edits made through the UI (which now draws `new_cls`)
1539
+ never reach the object everyone else reads. (Symptom: a class-var toggle like
1540
+ `Toggles.profile_mode` silently stops taking effect after a recompile.)
1541
+
1542
+ Hotswap's contract is that `old_cls` stays canonical, so we move every fresh
1543
+ registration onto it — keeping the newly-parsed decoration kwargs (e.g. an
1544
+ edited `@window(tint=...)`) but bound to the original identity.
1545
+
1546
+ Decorators that merely `setattr` dunders on the class (`@tint`, `@exclude`,
1547
+ `@no_save`, …) need no repair — hotswap already copied those onto `old_cls`.
1548
+ """
1549
+ if new_cls is old_cls:
1550
+ return
1551
+
1552
+ # @window - name-keyed; the re-exec OVERWROTE the entry with new_cls.
1553
+ wins = getattr(Melty, "annotated_window_classes", None)
1554
+ if isinstance(wins, dict):
1555
+ entry = wins.get(new_cls.__name__)
1556
+ if entry is not None and entry[0] is new_cls:
1557
+ wins[new_cls.__name__] = (old_cls, entry[1]) # keep fresh kwargs
1558
+
1559
+ # @defaults - class-keyed; the re-exec added a parallel new_cls entry. Move it
1560
+ # onto old_cls (replacing the pre-edit state) and drop the new_cls key.
1561
+ for reg_name in ("default_kwargs_by_type",
1562
+ "default_kwargs_by_attrib_type",
1563
+ "default_funcs_by_name_type"):
1564
+ reg = getattr(Melty, reg_name, None)
1565
+ if isinstance(reg, dict) and new_cls in reg:
1566
+ reg[old_cls] = reg.pop(new_cls)
1567
+
1568
+
1569
+ # Registries the function decorators (@render_func is_default_for / converter /
1570
+ # interrupt_source_for / is_lens_for, @window) write to. Snapshotted before a
1571
+ # recompile's exec and restored after, so the re-run decorators don't leave a
1572
+ # throwaway wrapper registered. render_funcs_by_name matters too: that's what
1573
+ # RenderFuncs.<name> default handles resolve through - left pointing at the
1574
+ # throwaway, every handle resolved after one recompile would freeze on it
1575
+ # (later edits patch the original raw, never the throwaway).
1576
+ _FUNC_REGISTRY_NAMES = ("default_funcs_by_type", "default_funcs_by_name",
1577
+ "type_interrupts", "_converters",
1578
+ "annotated_window_classes",
1579
+ "render_funcs_by_name", "default_lenses_by_type",
1580
+ # Shaped-keyed (shaped.py); same key → wrapper shape
1581
+ # as the others, so the reconcile needs nothing extra.
1582
+ "default_funcs_by_shape", "default_lenses_by_shape",
1583
+ # FIM registries (fim.py): @fim_provider /
1584
+ # @fim_context_source re-run on recompile too.
1585
+ "_fim_providers", "_fim_context_sources")
1586
+
1587
+ # Registries KEYED BY the wrapper object (reverse of the above). The re-run
1588
+ # decorator files the fresh entry under the throwaway key; move it onto the
1589
+ # original wrapper so edited converter flags/types take effect.
1590
+ _FUNC_KEYED_REGISTRY_NAMES = ("_converter_to_type", "converter_flags")
1591
+
1592
+
1593
+ def _snapshot_func_registrations() -> dict:
1594
+ """Shallow-copy the function registries BEFORE a recompile's exec, so we can
1595
+ tell which entries the re-run decorators overwrote and restore them."""
1596
+ snap = {}
1597
+ for name in _FUNC_REGISTRY_NAMES:
1598
+ reg = getattr(Melty, name, None)
1599
+ if isinstance(reg, dict):
1600
+ snap[name] = dict(reg)
1601
+ return snap
1602
+
1603
+
1604
+ def _patch_twin_raws(unwrapped):
1605
+ """Propagate an in-place hotswap to the SAME function's twin raw object(s).
1606
+
1607
+ One source file can sit in sys.modules under two names (the src./non-src
1608
+ dual identity: saved-state loaders import by the stored dotted path, which
1609
+ resurrects e.g. `lsd.gl_gui...` next to `src.lsd.gl_gui...`). Each twin
1610
+ module owns its OWN raw function and wrapper. A recompile patches whichever
1611
+ raw the editor resolved, but RenderFuncs.<name> handles resolve through
1612
+ render_funcs_by_name, which can hold the OTHER twin's wrapper — that twin
1613
+ keeps serving the stale code (the "RenderFuncs.button never updates" bug).
1614
+ Copy the freshly-patched state onto every same-qualname raw in every twin
1615
+ module so both identities run the edit.
1616
+
1617
+ Returns [(twin_raw, prev_state), ...] so the caller can fold the twins into
1618
+ its hotswap-guard rollback (see _restore_twin_raws)."""
1619
+ import sys
1620
+ code = getattr(unwrapped, "__code__", None)
1621
+ if code is None:
1622
+ return []
1623
+ try:
1624
+ target = Path(code.co_filename).resolve()
1625
+ except (OSError, ValueError):
1626
+ return []
1627
+ if "<locals>" in unwrapped.__qualname__:
1628
+ return [] # locals aren't reachable by attribute walk
1629
+ qual = unwrapped.__qualname__.split(".")
1630
+ target_name = target.name
1631
+ patched = []
1632
+ for mod in list(sys.modules.values()):
1633
+ f = getattr(mod, "__file__", None)
1634
+ # Cheap basename gate before the syscall-heavy resolve (same pattern as
1635
+ # chain_converters._modules_for_file).
1636
+ if not f or f.rsplit("/", 1)[-1].rsplit("\\", 1)[-1] != target_name:
1637
+ continue
1638
+ try:
1639
+ if Path(f).resolve() != target:
1640
+ continue
1641
+ except (OSError, ValueError):
1642
+ continue
1643
+ obj = mod
1644
+ for part in qual:
1645
+ obj = getattr(obj, part, None)
1646
+ if obj is None:
1647
+ break
1648
+ if obj is None or not callable(obj):
1649
+ continue
1650
+ try:
1651
+ twin = inspect.unwrap(obj)
1652
+ except Exception:
1653
+ continue
1654
+ if twin is unwrapped or getattr(twin, "__code__", None) is None:
1655
+ continue
1656
+ prev = (twin.__code__, twin.__defaults__, twin.__kwdefaults__,
1657
+ dict(twin.__annotations__ or {}), twin.__doc__)
1658
+ try:
1659
+ twin.__code__ = code
1660
+ except ValueError as e:
1661
+ # Mismatched freevars (differently-shaped closure twin) -
1662
+ # leave that twin alone rather than half-patch it.
1663
+ print(f"twin hotswap skipped for {mod.__name__}."
1664
+ f"{unwrapped.__qualname__}: {e}")
1665
+ continue
1666
+ twin.__defaults__ = unwrapped.__defaults__
1667
+ twin.__kwdefaults__ = unwrapped.__kwdefaults__
1668
+ twin.__annotations__ = dict(unwrapped.__annotations__ or {})
1669
+ twin.__doc__ = unwrapped.__doc__
1670
+ patched.append((twin, prev))
1671
+ return patched
1672
+
1673
+
1674
+ def _restore_twin_raws(twins) -> None:
1675
+ """Rollback half of _patch_twin_raws — used by the hotswap guard so a
1676
+ runtime-throwing edit reverts on BOTH module identities, not just the one
1677
+ the editor patched."""
1678
+ for twin, prev in twins:
1679
+ try:
1680
+ twin.__code__, twin.__defaults__, twin.__kwdefaults__, ann, twin.__doc__ = prev
1681
+ twin.__annotations__ = dict(ann)
1682
+ except Exception as ex:
1683
+ print(f"[hotswap_guard] twin restore failed: {ex}")
1684
+
1685
+
1686
+ def _redirect_function_registrations(pre_snapshot: dict, new_wrapper,
1687
+ live_raw=None, live_func=None,
1688
+ new_raw=None):
1689
+ """Reconcile the decorator-driven function registries after a recompile.
1690
+ Returns the resolved LIVE wrapper the entries were pointed at (None when no
1691
+ safe target was found and the registries were left alone).
1692
+
1693
+ The function analog of `_redirect_class_registrations`. `_recompile` re-execs
1694
+ a function's source, which RE-RUNS its decorators (`@render_func` is_default_for
1695
+ / converter / `interrupt_source_for`, `@window`). Those register a FRESH,
1696
+ throwaway wrapper in Melty's registries, while the app keeps editing/calling
1697
+ the ORIGINAL wrapper (which `@wraps`-wraps the raw function we hotswap in
1698
+ place, lives in vars(module), and `inspect.unwrap`s to the raw so both
1699
+ resolve_address and shift_sibling_linenos stay correct). The throwaway is
1700
+ doubly wrong: it serves stale renders, and — never being in vars(module) —
1701
+ its co_firstlineno rots so resolve_address eventually returns start=0.
1702
+
1703
+ We must point every registration the fresh source declares at the LIVE wrapper,
1704
+ NOT the throwaway, AND drop registrations the edit removed. Simply restoring the
1705
+ pre-edit entry (the old behaviour) only handled keys that already existed: an
1706
+ is_default_for type ADDED by the edit was left pointing at the dead throwaway,
1707
+ and one REMOVED was left stale — so editing is_default_for silently lost the new
1708
+ type. Reconciling against the live wrapper makes add/change/remove all take.
1709
+
1710
+ live_wrapper resolution: the module-global binding (`name` in the raw's globals)
1711
+ IS the wrapper the app holds — we hotswap the raw in place and never rebind the
1712
+ name, so it stays valid across runs. We must NOT register the bare raw (the
1713
+ editor may hand `_recompile` the raw via draw_state._view_func): draw_any would
1714
+ then call it without the injected draw_state/depth/style_manager/meta. Fall back
1715
+ to a snapshot value that unwraps to the raw, then to the editor's object.
1716
+ """
1717
+ if new_wrapper is None:
1718
+ return None
1719
+
1720
+ def _unwraps_to(v, raw):
1721
+ if raw is None:
1722
+ return False
1723
+ try:
1724
+ return inspect.unwrap(v) is raw
1725
+ except Exception:
1726
+ return False
1727
+
1728
+ # Resolve the live wrapper the app keeps calling for this function.
1729
+ live_wrapper = None
1730
+ if live_raw is not None:
1731
+ cand = getattr(live_raw, "__globals__", {}).get(
1732
+ getattr(live_raw, "__name__", None))
1733
+ if cand is not None and cand is not new_wrapper and _unwraps_to(cand, live_raw):
1734
+ live_wrapper = cand
1735
+ if live_wrapper is None:
1736
+ for snap in pre_snapshot.values():
1737
+ for v in snap.values():
1738
+ if v is not new_wrapper and _unwraps_to(v, live_raw):
1739
+ live_wrapper = v
1740
+ break
1741
+ if (isinstance(v, tuple) and len(v) == 2
1742
+ and v[0] is not new_wrapper and _unwraps_to(v[0], live_raw)):
1743
+ live_wrapper = v[0]
1744
+ break
1745
+ if live_wrapper is not None:
1746
+ break
1747
+ if live_wrapper is None:
1748
+ live_wrapper = live_func
1749
+ if live_wrapper is None:
1750
+ return None # no safe target - leave the registries untouched
1751
+
1752
+ def _is_live(v):
1753
+ return (v is live_wrapper or v is new_wrapper or _unwraps_to(v, live_raw))
1754
+
1755
+ def _is_fresh(v):
1756
+ # The re-run decorators register the throwaway WRAPPER - or, in a
1757
+ # `@window`/`@defaults` written below @render_func, the throwaway RAW
1758
+ # (render_func's _adopt_raw_registrations normally re-points it, but
1759
+ # we the raw, so an un-adopted entry never survives as a rotting
1760
+ # object that draws the window / drifts resolve_address).
1761
+ return v is new_wrapper or (new_raw is not None and v is new_raw)
1762
+
1763
+ for name, before in pre_snapshot.items():
1764
+ reg = getattr(Melty, name, None)
1765
+ if not isinstance(reg, dict):
1766
+ continue
1767
+ is_window = (name == "annotated_window_classes")
1768
+
1769
+ # Keys the fresh exec just registered (value points at the throwaway).
1770
+ fresh = {}
1771
+ for key, val in list(reg.items()):
1772
+ if is_window:
1773
+ if isinstance(val, tuple) and len(val) == 2 and _is_fresh(val[0]):
1774
+ fresh[key] = val[1] # keep the fresh @window kwargs
1775
+ elif _is_fresh(val):
1776
+ fresh[key] = None
1777
+
1778
+ # Keys this function owned BEFORE the edit.
1779
+ old_keys = set()
1780
+ for key, val in before.items():
1781
+ if is_window:
1782
+ if isinstance(val, tuple) and len(val) == 2 and _is_live(val[0]):
1783
+ old_keys.add(key)
1784
+ elif _is_live(val):
1785
+ old_keys.add(key)
1786
+
1787
+ # Install every fresh registration under the LIVE wrapper.
1788
+ for key, win_kwargs in fresh.items():
1789
+ reg[key] = (live_wrapper, win_kwargs) if is_window else live_wrapper
1790
+
1791
+ # Drop registrations the edit removed (owned before, not re-registered now).
1792
+ # Guard against clobbering an entry another function has since claimed.
1793
+ for key in old_keys - set(fresh):
1794
+ cur = reg.get(key)
1795
+ if is_window:
1796
+ if isinstance(cur, tuple) and len(cur) == 2 and _is_live(cur[0]):
1797
+ reg.pop(key, None)
1798
+ elif _is_live(cur):
1799
+ reg.pop(key, None)
1800
+
1801
+ # Non-registereded registries: the fresh entry sits under the throwaway key;
1802
+ # re-key it onto the live wrapper so edited flags/types take effect.
1803
+ for reg_name in _FUNC_KEYED_REGISTRY_NAMES:
1804
+ reg = getattr(Melty, reg_name, None)
1805
+ if isinstance(reg, dict) and new_wrapper in reg:
1806
+ reg[live_wrapper] = reg.pop(new_wrapper)
1807
+ return live_wrapper
1808
+
1809
+
1810
+ def _transfer_wrapper_state(live_wrapper, new_wrapper, new_raw) -> None:
1811
+ """Copy decoration-time state from the freshly-exec'd wrapper onto the LIVE one.
1812
+
1813
+ @render_func computes its config ONCE at decoration time — o_kwargs,
1814
+ header_defaults, wanted_params, the param-injection tables — into the
1815
+ wrapper's closure cells and a few wrapper attributes. Hotswap patches the
1816
+ raw function's __code__ in place and keeps the ORIGINAL wrapper canonical,
1817
+ so without this transfer an edited decorator line
1818
+ (`@render_func(tint=..., use_cache=...)`) or a changed signature default
1819
+ re-runs onto the throwaway wrapper only and never reaches the wrapper the
1820
+ app actually calls — "decorations aren't rerun".
1821
+
1822
+ Both wrappers are instances of the SAME core_render `wrapper` code object,
1823
+ so their co_freevars align cell-for-cell. Copy every cell EXCEPT the
1824
+ identity ones: a cell holding the throwaway raw (`func`) must keep pointing
1825
+ at the live raw we patch in place, and a self-reference cell (`wrapper`)
1826
+ must keep pointing at the live wrapper."""
1827
+ lc = getattr(live_wrapper, "__closure__", None)
1828
+ nc = getattr(new_wrapper, "__closure__", None)
1829
+ if (live_wrapper.__code__ is not new_wrapper.__code__
1830
+ or lc is None or nc is None or len(lc) != len(nc)):
1831
+ return
1832
+ # The wrapper's closure also holds the helpers `render_func` defines local
1833
+ # to it (draw_inner_main, _auto_state_params, ...). Those are NOT config:
1834
+ # they share the live wrapper's wrapper, so their config reached them
1835
+ # already - and the throwaway's copies close over the throwaway raw, so
1836
+ # copying draw_inner_main made the live wrapper run the throwaway body
1837
+ # (exec'd into a COPY of the module globals: the file browser kept
1838
+ # reading a stale `current` after a tint-chip edit, 09-13). Skip every
1839
+ # sibling helper and anything else that closes over the throwaway.
1840
+ nested_prefix = new_wrapper.__code__.co_qualname.rsplit(".", 1)[0] + "."
1841
+
1842
+ def _is_throwaway_helper(content):
1843
+ if not isinstance(content, types.FunctionType):
1844
+ return False
1845
+ if content.__code__.co_qualname.startswith(nested_prefix):
1846
+ return True
1847
+ for cell in content.__closure__ or ():
1848
+ try:
1849
+ v = cell.cell_contents
1850
+ except ValueError:
1851
+ continue
1852
+ if v is new_raw or v is new_wrapper:
1853
+ return True
1854
+ return False
1855
+
1856
+ for live_cell, new_cell in zip(lc, nc):
1857
+ try:
1858
+ content = new_cell.cell_contents
1859
+ except ValueError:
1860
+ continue
1861
+ if content is new_raw or content is new_wrapper or _is_throwaway_helper(content):
1862
+ continue
1863
+ try:
1864
+ live_cell.cell_contents = content
1865
+ except ValueError:
1866
+ pass
1867
+
1868
+ # Decorator-set wrapper ATTRIBUTES (not closure): chain-dispatch handles,
1869
+ # search flag, header defaults. Copy fresh values; drop ones the edit
1870
+ # removed. NEVER __wrapped__ - it must keep pointing at the live raw.
1871
+ for attr in ("_load_data", "_save_data", "_searchable",
1872
+ "__header_defaults__", "__params__", "multi_instance"):
1873
+ if hasattr(new_wrapper, attr):
1874
+ try:
1875
+ setattr(live_wrapper, attr, getattr(new_wrapper, attr))
1876
+ except (AttributeError, TypeError):
1877
+ pass
1878
+ elif attr in ("_load_data", "_save_data", "_searchable"):
1879
+ try:
1880
+ delattr(live_wrapper, attr)
1881
+ except AttributeError:
1882
+ pass
1883
+
1884
+
1885
+ def _redirect_function_decorations(new_wrapper, new_raw,
1886
+ live_wrapper, live_raw) -> None:
1887
+ """Re-key the `@defaults`-style registrations a function recompile re-ran.
1888
+
1889
+ The function analog of the `@defaults` handling in
1890
+ `_redirect_class_registrations`. `_recompile`'s exec re-runs the function's
1891
+ decorators, so a `@defaults(...)` above it (functions can carry it too — e.g.
1892
+ `icon_tint` in toggles.py) re-registers, but KEYED TO THE THROWAWAY function
1893
+ exec produced (`@defaults` keys by the object it decorates). Like the wrapper
1894
+ registries (`_redirect_function_registrations`, which re-points each entry at the
1895
+ LIVE wrapper rather than the throwaway), we want the FRESH value to win —
1896
+ otherwise editing a function's `@defaults` decoration would register the new
1897
+ value under the throwaway and leave the live function on the stale one. The
1898
+ difference is keying: those registries hold the wrapper, these hold the value
1899
+ keyed BY the function, so we move/clear by the live function object instead.
1900
+
1901
+ To make EVERY run (not just the first) reflect exactly the current source, we
1902
+ fully swap rather than merge: pull the freshly-registered entry off the
1903
+ throwaway, drop the live function's prior entry under EITHER key, then reinstall
1904
+ the fresh one under the matching live key. This way a decoration whose value
1905
+ changed is updated, and one that was deleted entirely leaves no fresh entry, so
1906
+ the stale value is simply cleared instead of lingering across runs. Depending on
1907
+ decorator order `@defaults` keys by the wrapper or the raw function, so check
1908
+ both; clearing both live keys also sidesteps the no-wrapper case (wrapper IS
1909
+ raw) double-popping the entry we just installed.
1910
+ """
1911
+ pairs = ((new_wrapper, live_wrapper), (new_raw, live_raw))
1912
+ for reg_name in ("default_kwargs_by_type",
1913
+ "default_kwargs_by_attrib_type",
1914
+ "default_funcs_by_name_type"):
1915
+ reg = getattr(Melty, reg_name, None)
1916
+ if not isinstance(reg, dict):
1917
+ continue
1918
+ # Lift the fresh entry off whichever throwaway key the decorator used, and
1919
+ # remember the live key it should land on (wrapper-keyed → live wrapper,
1920
+ # raw-keyed → live raw, matching where the prior import-time registration
1921
+ # - and thus the render-time lookup - lives).
1922
+ fresh, target = None, None
1923
+ for new_key, live_key in pairs:
1924
+ if new_key is not None and new_key in reg:
1925
+ fresh, target = reg.pop(new_key), live_key
1926
+ break
1927
+ # Drop the live function's stale entry under either key (handles a
1928
+ # removed/renamed decoration), then reinstall the fresh one if present.
1929
+ for live_key in (live_wrapper, live_raw):
1930
+ if live_key is not None:
1931
+ reg.pop(live_key, None)
1932
+ if fresh is not None and target is not None:
1933
+ reg[target] = fresh