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,1032 @@
1
+ """
2
+ load_save_v2 — native-pickle load/save (Strategy B prototype).
3
+
4
+ Goal: reproduce the object graph that DictConversion.to_dict/from_dict produce —
5
+ including the SHARING TOPOLOGY (aliases + cycles) — but via pickle, in one C-driven
6
+ pass, and general enough to also serialize plain (non-DictConversion) Python objects.
7
+
8
+ It is NOT vanilla pickle. A custom Pickler/Unpickler pair re-hosts the four
9
+ resilience layers the dict system provides, so that none of pickle's sharp edges
10
+ (see module docstring of dict_conversion.py) bite:
11
+
12
+ 1. EXCLUSION — drop _-prefixed + @exclude/@no_save attrs (mirrors to_dict's
13
+ selection); stub external resources (tensors/Module/GL/weakref)
14
+ to None via persistent_id. The serialized scope therefore equals
15
+ to_dict's scope: config/UI state, no GPU payload.
16
+ 2. SEEDING — reconstruct via cls.__new__ + a cheap default-seed (__post_init__,
17
+ __field_defaults__) then OVERLAY saved state. Skips the slow cls()
18
+ path while still giving newly-added fields their class defaults.
19
+ 3. SCHEMA-SAFE — because state is overlaid onto a defaulted instance, adding /
20
+ removing / renaming a field degrades exactly like the current
21
+ system (new field -> default; removed field -> ignored).
22
+ 4. RESILIENCE — enums reduced BY NAME (values churn here), callables by registry/
23
+ module reference, classes resolved through ClassUtility's fuzzy
24
+ finder so a moved/nested class still loads.
25
+
26
+ Post-load, load() walks the restored graph and fires on_load(vis, root), mirroring
27
+ from_dict's final pass.
28
+ """
29
+ import copy
30
+ import io
31
+ import logging
32
+ import os
33
+ import pickle
34
+ import sys
35
+ import types
36
+ import weakref
37
+ from enum import Enum
38
+
39
+ # These imports are heavy but already resolved whenever the app is running.
40
+ from meltygui.core.conversion.dict_conversion import DictConversion
41
+ from meltygui.core.conversion.dict_conversion_util import ClassUtility
42
+ from meltygui.state.core_enums import generate_id
43
+ from meltygui.core.conversion.missing_saved_class import missing_saved_class
44
+ from meltygui.core.conversion.missing_saved_class import restore_saved_class
45
+
46
+ log = logging.getLogger("load_save_v2")
47
+
48
+
49
+ # ────────────────────────────────────────────────────────────────────────────
50
+ # external-resource detection (stubbed to None via persistent_id)
51
+ # ────────────────────────────────────────────────────────────────────────────
52
+ # Names mirror the three existing exclude lists (compute_hash, deepcopy_exclude,
53
+ # the lsd_studio save call). We detect by TYPE so a stray handle anywhere in the
54
+ # graph is caught regardless of attribute name.
55
+ _EXTERNAL_TYPE_NAMES = {
56
+ "Tensor", "Parameter", # torch
57
+ "Module", # nn.Module (matched via mro search)
58
+ "RegisteredBuffer", "GLBuffer", "GLTexture", "Framebuffer",
59
+ "DeviceAllocation", "RegisteredImage", # pycuda
60
+ "TensorXYRenderer", "VolumeRendererFBO", # GL renderers (xy_renderer/xyz_renderer)
61
+ "GLState",
62
+ }
63
+ _EXTERNAL_MODULE_HINTS = ("pycuda", "OpenGL", "glfw")
64
+
65
+
66
+ _WEAK_TYPES = (weakref.ReferenceType, weakref.ProxyType, weakref.CallableProxyType,
67
+ weakref.WeakSet, weakref.WeakValueDictionary, weakref.WeakKeyDictionary)
68
+
69
+ # persistent_id runs on each object pickled - the type->bool verdict is memoized.
70
+ # WeakKeyDictionary so hotswapped/GC'd classes don't retain stale verdicts (M3).
71
+ _external_cache = weakref.WeakKeyDictionary()
72
+
73
+
74
+ def _torch_types():
75
+ """Lazily resolve (Tensor, nn.Module) for a direct, alias-proof issubclass
76
+ check alongside the string match (L3). Cached; torch is already imported when
77
+ the app runs."""
78
+ cached = getattr(_torch_types, "_cache", False)
79
+ if cached is False:
80
+ try:
81
+ import torch
82
+ cached = (torch.Tensor, torch.nn.Module)
83
+ except Exception:
84
+ cached = None
85
+ _torch_types._cache = cached
86
+ return cached
87
+
88
+
89
+ def _numpy_generic():
90
+ """Lazily resolve numpy.generic (base of ALL numpy scalars: np.float64,
91
+ np.int64, np.bool_, …) so they can be converted to Python primitives. Cached;
92
+ None if numpy is absent."""
93
+ cached = getattr(_numpy_generic, "_cache", False)
94
+ if cached is False:
95
+ try:
96
+ import numpy
97
+ cached = numpy.generic
98
+ except Exception:
99
+ cached = None
100
+ _numpy_generic._cache = cached
101
+ return cached
102
+
103
+
104
+ def _recover_numpy_scalar(dtype, *args):
105
+ """find_class hands numpy's scalar reconstructor to THIS on load. New saves
106
+ convert numpy scalars to Python primitives (so no numpy.scalar in the wire),
107
+ but a LEGACY pkl written before that fix dropped the dtype to None — without a
108
+ dtype the bytes can't be decoded, so recover as 0.0 rather than crash the load."""
109
+ if dtype is None:
110
+ return 0.0
111
+ try:
112
+ import numpy
113
+ return numpy.core.multiarray.scalar(dtype, *args)
114
+ except Exception:
115
+ return 0.0
116
+
117
+
118
+ def _compute_is_external(t):
119
+ if issubclass(t, _WEAK_TYPES):
120
+ return True
121
+ tt = _torch_types()
122
+ if tt is not None and issubclass(t, tt): # direct torch.Tensor / nn.Module
123
+ return True
124
+ if t.__name__ in _EXTERNAL_TYPE_NAMES:
125
+ return True
126
+ mod = getattr(t, "__module__", "") or ""
127
+ if any(h in mod for h in _EXTERNAL_MODULE_HINTS):
128
+ return True
129
+ if mod in ("threading", "_thread", "queue", "multiprocessing"):
130
+ return True
131
+ for base in t.__mro__: # torch tensors / modules / tokenizers (string fallback)
132
+ bn, bm = base.__name__, getattr(base, "__module__", "") or ""
133
+ if bn == "Tensor" and bm.startswith("torch"):
134
+ return True
135
+ if bm.startswith("torch.nn.modules"):
136
+ return True
137
+ if "PreTrainedTokenizer" in bn:
138
+ return True
139
+ return False
140
+
141
+
142
+ def _is_external(obj):
143
+ """True for resources that must never be pickled inline (restored as None)."""
144
+ t = type(obj)
145
+ v = _external_cache.get(t)
146
+ if v is None:
147
+ v = _compute_is_external(t)
148
+ try:
149
+ _external_cache[t] = v
150
+ except TypeError: # non-weakreferenceable type key - we don't cache
151
+ pass
152
+ return v
153
+
154
+
155
+ _DATA_PRIMITIVE = (int, float, bool, complex, str, bytes, bytearray, type(None))
156
+
157
+
158
+ def _is_foreign(obj):
159
+ """Catch-all for objects that are neither our data nor picklable Python state:
160
+ C-extension handles (glfw windows, GL/imgui/pybind objects with a raw ``_ptr``),
161
+ etc. They're stubbed to None — mirroring to_dict's "unknown non-primitive -> None"
162
+ fallthrough, which is exactly why the current save survives a live root full of
163
+ live handles.
164
+
165
+ A plain Python object WITH a real instance ``__dict__`` is NOT foreign: the
166
+ general-purpose reduce path handles it (so v2 works on arbitrary classes, not
167
+ just DictConversion). Only handle-like objects with no picklable ``__dict__``
168
+ are dropped."""
169
+ if isinstance(obj, _DATA_PRIMITIVE):
170
+ return False
171
+ if isinstance(obj, (list, tuple, dict, set, frozenset)):
172
+ return False
173
+ if isinstance(obj, (Enum, type)):
174
+ return False
175
+ if isinstance(obj, DictConversion):
176
+ return False
177
+ if callable(obj): # functions/methods/handles -> reducer_override
178
+ return False
179
+ return not isinstance(getattr(obj, "__dict__", None), dict)
180
+
181
+
182
+ _HEAPTYPE = 1 << 9 # Py_TPFLAGS_HEAPTYPE
183
+
184
+
185
+ def _unsafe_to_reconstruct(obj):
186
+ """Final safety net for the generic reduce path: instances of NON-HEAP
187
+ C-extension types can't be safely ``cls.__new__()``'d on load (weakref
188
+ proxies, datetime, pybind/cython objects, …). Everything we genuinely handle
189
+ is excluded here; the rest are dropped to None, mirroring to_dict dropping
190
+ unknown non-primitives. Prevents 'object.__new__(X) is not safe' load crashes
191
+ from any C object that slipped past the specific filters."""
192
+ t = type(obj)
193
+ if t.__module__ == "builtins":
194
+ return False # int/str/list/tuple/dict/fn/... native
195
+ if isinstance(obj, (Enum, type, DictConversion)):
196
+ return False
197
+ if isinstance(obj, (types.FunctionType, types.BuiltinFunctionType, types.MethodType)):
198
+ return False
199
+ try:
200
+ return not (t.__flags__ & _HEAPTYPE)
201
+ except Exception:
202
+ return False
203
+
204
+
205
+ _unpicklable_class_cache = weakref.WeakKeyDictionary()
206
+
207
+
208
+ def _compute_unpicklable_class(obj):
209
+ mod = getattr(obj, "__module__", None)
210
+ qn = getattr(obj, "__qualname__", None)
211
+ if not mod or not qn or "<locals>" in qn:
212
+ return True
213
+ m = sys.modules.get(mod)
214
+ if m is None:
215
+ return True
216
+ target = m # walk the qualname (handles nesting)
217
+ for part in qn.split("."):
218
+ target = getattr(target, part, None)
219
+ if target is None:
220
+ return True
221
+ return target is not obj # found a DIFFERENT object -> not referenceable
222
+
223
+
224
+ def _is_unpicklable_class(obj):
225
+ """A class object pickle can't reference by qualname — dynamically generated
226
+ (the bubbling converter classes: Bubbling_GeneralParse etc.) or `<locals>`.
227
+ Stub to None for now (the bubbling classes are runtime-built; long-term they
228
+ could be made pickleable). Mirrors pickle's own findability test."""
229
+ if not isinstance(obj, type):
230
+ return False
231
+ v = _unpicklable_class_cache.get(obj)
232
+ if v is None:
233
+ v = _compute_unpicklable_class(obj)
234
+ try:
235
+ _unpicklable_class_cache[obj] = v
236
+ except TypeError:
237
+ pass
238
+ return v
239
+
240
+
241
+ # ────────────────────────────────────────────────────────────────────────────
242
+ # schema-safe reconstruction
243
+ # ────────────────────────────────────────────────────────────────────────────
244
+ # Hardcoded suppression set, from from_dict's BASE_EXCLUDED (dict_conversion 72-75).
245
+ # The non-underscore members matter (underscore'd are caught by the _-rule):
246
+ # class_names / hash / outliner_expanded_h / modules_imported.
247
+ # NOTE (recon correction): @exclude / __excluded_attrs__ does NOT suppress
248
+ # serialization - it only suppresses @live invalidation. The real serialization
249
+ # suppressors are __no_save__ (@no_save) and the instance's `excluded` attr
250
+ # (to_dict 232-249). So `id`/`tint`/`name` (which are only in the base @exclude)
251
+ # ARE serialized today - and will be here too, which also keeps `id` STABLE across
252
+ # the round-trip (resolving recon open-risk 6.5).
253
+ _BASE_SUPPRESS = frozenset({
254
+ "class_names", "hash", "outliner_expanded_h", "modules_imported",
255
+ "_parent", "_parent_key", "_children",
256
+ })
257
+
258
+ # The runtime-only field names the studio drops at its to_dict call site
259
+ # (lsd_studio.py:8567). The save call must pass this (or its own list) as
260
+ # `excluded=` so v2 doesn't serialize live GPU/GL-adjacent state / caches.
261
+ STUDIO_SAVE_EXCLUDED = frozenset({
262
+ "search_results", "previous_mouse_x", "previous_mouse_y", "last_mouse_x", "last_mouse_y",
263
+ "root", "ui_stack", "view_state", "tooltip_position", "tooltip_value", "state",
264
+ "start_render_time", "selected_index", "tooltip_height", "total_render_time",
265
+ "selected_box", "cuda_buffer", "buffer", "all_settings", "labels",
266
+ "layer_hashes", "sub_layer_hashes",
267
+ })
268
+
269
+
270
+ def _suppressed(obj, excluded=()):
271
+ s = set(_BASE_SUPPRESS)
272
+ if excluded:
273
+ s |= set(excluded) # caller-specific field drops (C1)
274
+ ns = getattr(type(obj), "__no_save__", None)
275
+ if ns:
276
+ s |= set(ns)
277
+ inst_excl = getattr(obj, "excluded", None) # to_dict reads self.excluded
278
+ if inst_excl:
279
+ try:
280
+ s |= set(inst_excl)
281
+ except TypeError:
282
+ pass
283
+ return s
284
+
285
+
286
+ _MISSING = object()
287
+
288
+
289
+ def _equals_default(v, dv):
290
+ """to_dict's omit rules: scalar/enum/tuple/None equal to default; an EMPTY
291
+ container whose default is also empty; a DictConversion that IS the default
292
+ (identity). Non-empty containers are always kept."""
293
+ if isinstance(v, (int, float, str, bool, bytes, Enum, tuple, type(None))):
294
+ try:
295
+ return bool(v == dv)
296
+ except Exception:
297
+ return False
298
+ if isinstance(v, (dict, list, set)):
299
+ return len(v) == 0 and isinstance(dv, (dict, list, set)) and len(dv) == 0
300
+ if isinstance(v, DictConversion):
301
+ # Identity with the default is necessary but NOT sufficient: when a
302
+ # field was absent from the old pickle, _reconstruct fills it with the
303
+ # default instance's OWN object (plain Python, shared identity) - and
304
+ # runtime mutations then land inside that shared default. Omitting on
305
+ # identity alone silently discarded such state on every save (this ate
306
+ # GlobalSearchStore.counts). Omit only when the object is also
307
+ # pristine, i.e. carries no diverged state of its own.
308
+ if v is not dv:
309
+ return False
310
+ try:
311
+ return not _save_state(v)
312
+ except Exception:
313
+ return False
314
+ return False
315
+
316
+
317
+ def _save_state(obj, excluded=()):
318
+ """The attributes load/save owns, mirroring to_dict's selection AND its
319
+ DELTA-FROM-DEFAULT encoding: public (non-_) ∩ not (BASE_EXCLUDED ∪
320
+ caller-excluded ∪ __no_save__ ∪ self.excluded), with any field equal to the
321
+ class default OMITTED. cls() reconstruction seeds the omitted fields back to
322
+ their defaults on load, so the omission is lossless.
323
+
324
+ Two reasons this matters (a full snapshot ballooned the file ~20x vs the
325
+ legacy .ini and let runtime state accrete):
326
+ * smaller blob — only diverging fields are written;
327
+ * iterate the DEFAULT instance's public keys (like to_dict), so attributes
328
+ that exist on `obj` but not on a fresh default (runtime-injected) are NOT
329
+ serialized and can't accumulate across save/load cycles."""
330
+ if getattr(type(obj), "__missing_saved_path__", None):
331
+ # There is no schema/default to compare against. Every recovered field
332
+ # belongs to the saved payload; delta filtering would erase it again.
333
+ return {key: value for key, value in vars(obj).items()
334
+ if not key.startswith("_") and key not in excluded}
335
+ sup = _suppressed(obj, excluded)
336
+ cls = type(obj)
337
+ default = getattr(cls, "default_instance", None)
338
+ if default is None:
339
+ try:
340
+ default = cls()
341
+ except Exception:
342
+ default = None
343
+ # Iterate the default's public keys (to_dict parity); fall back to obj's own.
344
+ src = default if default is not None else obj
345
+ out = {}
346
+ for k in list(vars(src).keys()):
347
+ if k.startswith("_") or k in sup:
348
+ continue
349
+ v = getattr(obj, k, None)
350
+ if isinstance(v, types.MethodType): # @live-injected bound methods
351
+ continue
352
+ if default is not None:
353
+ dv = getattr(default, k, _MISSING)
354
+ if dv is not _MISSING and _equals_default(v, dv):
355
+ continue
356
+ out[k] = v
357
+ # dlt_count prune countdown (to_dict's mechanism, dict_conversion:265-269).
358
+ # ALWAYS serialize it decremented - bypassing the delta-omission above - so an
359
+ # unused object counts down across saves to <= 0, at which point persistent_id
360
+ # drops it. Without this, draw_state_registry grows unbounded (dlt_count would
361
+ # remain at its default and never count down). Used objects are subject to
362
+ # save_draw_state_for (=1) each render, so they survive; unused ones don't.
363
+ dc = getattr(obj, "dlt_count", None)
364
+ if type(dc) is int:
365
+ out["dlt_count"] = dc - 1
366
+ return out
367
+
368
+
369
+ # The @live new_init injection plan (which names to copy into __dict__) depends
370
+ # only on the class - cache the dir(cls) walk ONCE per class, not per instance.
371
+ # WeakKeyDictionary so hotswapped classes don't pin stale plans (M3).
372
+ _live_inject_cache = weakref.WeakKeyDictionary()
373
+
374
+
375
+ def _live_inject_plan(cls):
376
+ plan = _live_inject_cache.get(cls)
377
+ if plan is None:
378
+ plan = []
379
+ try:
380
+ from meltygui.core.rendering.core_decoration import auto_eval
381
+ except ImportError:
382
+ auto_eval = () # module absent - empty plan (L1)
383
+ for name in dir(cls):
384
+ try:
385
+ attr = getattr(cls, name, None)
386
+ if callable(attr) and getattr(attr, "_add_to_dict", False):
387
+ plan.append(("method", name))
388
+ elif auto_eval and isinstance(attr, auto_eval) and attr.fget is not None:
389
+ plan.append(("auto_eval", name, attr))
390
+ except Exception:
391
+ continue # a single bad descriptor shouldn't nuke the rest
392
+ try:
393
+ _live_inject_cache[cls] = plan
394
+ except TypeError:
395
+ pass
396
+ return plan
397
+
398
+
399
+ def _seed_defaults(obj):
400
+ """Cheaply bring a __new__'d instance up to 'freshly constructed' defaults,
401
+ WITHOUT the slow cls() path, so saved state can overlay and new fields default.
402
+
403
+ Replicates the construction side-effects pickle's __new__ skips (recon §2):
404
+ 1. re-arm the @live init guard (so __post_init__'s setattrs don't invalidate)
405
+ 2. @live new_init descriptor injection (_add_to_dict methods + auto_eval)
406
+ 3. __post_init__ scaffolding (_parent/_children/id/hash/tint/_exclude_attrs)
407
+ 4. __field_defaults__ backfill (FieldMeta class-body field defaults)
408
+ 5. _instances registration
409
+ Class-body field defaults not backfilled still resolve via the class attribute,
410
+ so getattr(obj, new_field) returns the default regardless — schema-add safe.
411
+ """
412
+ cls = type(obj)
413
+ # Must match @live new_setattr's guard name exactly (invalidation_decoration.py:19)
414
+ # so __post_init__'s setattrs are suppressed. (M4's id(cls) rename would desync
415
+ # this from @live's naming scheme; the identically-named-nested-class collision is
416
+ # @live's documented behavior, not ours to change unilaterally.)
417
+ init_flag = f"__{cls.__name__}_initializing__"
418
+ object.__setattr__(obj, init_flag, True)
419
+ try:
420
+ # (2) @live new_init loop: inject _add_to_dict methods + auto_eval descrs
421
+ # (plan is cached per-class so this is a short list walk, not dir(cls))
422
+ for entry in _live_inject_plan(cls):
423
+ try:
424
+ if entry[0] == "method":
425
+ obj.__dict__[entry[1]] = getattr(obj, entry[1])
426
+ else:
427
+ obj.__dict__[entry[1]] = entry[2].fget.__get__(obj, cls)
428
+ except Exception:
429
+ pass
430
+ # (3) __post_init__
431
+ post = getattr(obj, "__post_init__", None)
432
+ if callable(post):
433
+ try:
434
+ post()
435
+ except Exception:
436
+ pass
437
+ # (4) field defaults. Deepcopy MUTABLE containers so seeded instances don't
438
+ # alias one shared class-default list/dict (H5 - FieldMeta.__call__ has the
439
+ # same original bug; fix it once default-diffing omits these on save).
440
+ for k, v in getattr(cls, "__field_defaults__", {}).items():
441
+ if k not in obj.__dict__:
442
+ if isinstance(v, (dict, list, set)):
443
+ try:
444
+ v = copy.deepcopy(v)
445
+ except Exception:
446
+ pass
447
+ try:
448
+ object.__setattr__(obj, k, v)
449
+ except Exception:
450
+ pass
451
+ # (5) instance registry
452
+ insts = getattr(cls, "_instances", None)
453
+ if insts is not None:
454
+ try:
455
+ insts.add(obj)
456
+ except Exception:
457
+ pass
458
+ finally:
459
+ object.__setattr__(obj, init_flag, False)
460
+
461
+
462
+ def _default_for(cls):
463
+ """The cached pristine default instance (cls.default_instance), created once if
464
+ absent (DictConversion.__init__ caches it). Template for fast reconstruction."""
465
+ d = cls.__dict__.get("default_instance") # this class's own, never an inherited base's
466
+ if d is not None:
467
+ return d
468
+ try:
469
+ cls() # side effect: caches cls.default_instance
470
+ except Exception:
471
+ return None
472
+ return cls.__dict__.get("default_instance")
473
+
474
+
475
+ # During a load, every reconstructed DictConversion is appended here so _post_load
476
+ # can fire on_load WITHOUT re-walking the whole graph (that walk pushed every
477
+ # primitive __dict__ value - millions of list.pop/id() calls). Set to a list by
478
+ # loads(); None otherwise.
479
+ _collect = None
480
+
481
+ # Per-class copy plan: which default keys are plain (just assign), mutable
482
+ # CONTAINERS (need a fresh per-instance .copy()), or SELF-REFERENCES (rebind to the
483
+ # new instance - DrawState._parent = self). Computed ONCE per class from its default
484
+ # so _reconstruct doesn't isinstance() every attribute of every object (that was
485
+ # ~3.6M isinstance calls per load). 'id'/'hash' are set explicitly, skipped here.
486
+ _copy_plan_cache = weakref.WeakKeyDictionary()
487
+
488
+
489
+ def _copy_plan(cls, default):
490
+ plan = _copy_plan_cache.get(cls)
491
+ if plan is None:
492
+ plain, containers, selfrefs = [], [], []
493
+ for k, v in default.__dict__.items():
494
+ if k in ("id", "hash") or isinstance(v, types.MethodType):
495
+ continue # set explicitly or @live-injection
496
+ if v is default:
497
+ selfrefs.append(k)
498
+ elif isinstance(v, (dict, list, set)):
499
+ containers.append(k)
500
+ else:
501
+ plain.append(k)
502
+ plan = (tuple(plain), tuple(containers), tuple(selfrefs))
503
+ try:
504
+ _copy_plan_cache[cls] = plan
505
+ except TypeError:
506
+ pass
507
+ return plan
508
+
509
+
510
+ def _reconstruct(cls):
511
+ """Reconstruct by COPYING the cached default instance's __dict__ — NOT by
512
+ re-running __init__ per object. The default already ran the full
513
+ __init__/__post_init__/FieldMeta chain once, so it carries every attribute
514
+ (incl. underscore attrs set only in __init__, e.g. _snapshot_visible). Copying
515
+ it skips ~270 @live-wrapped setattrs PER object (DrawState.__init__ alone fired
516
+ ~1.7M -> ~1.4s on the live graph). Pickle then overlays the saved public state.
517
+
518
+ Uses a cached per-class plan (no per-attribute isinstance), rebinds self-loops
519
+ (DrawState._parent = self) to this instance, gives each instance fresh mutable
520
+ containers via .copy() (defaultdict-safe), and a unique id (overlaid if saved).
521
+ Falls back to full cls() if no default is available."""
522
+ default = _default_for(cls)
523
+ if default is None:
524
+ try:
525
+ obj = cls()
526
+ except Exception:
527
+ obj = cls.__new__(cls)
528
+ if _collect is not None:
529
+ _collect.append(obj)
530
+ return obj
531
+
532
+ obj = cls.__new__(cls)
533
+ d = obj.__dict__
534
+ dd = default.__dict__
535
+ plain, containers, selfrefs = _copy_plan(cls, default)
536
+ for k in plain:
537
+ d[k] = dd[k]
538
+ for k in containers:
539
+ cv = dd[k]
540
+ try:
541
+ d[k] = cv.copy() # dict/list/set/defaultdict all have .copy()
542
+ except Exception:
543
+ d[k] = cv
544
+ for k in selfrefs:
545
+ d[k] = obj
546
+ d["id"] = generate_id() # unique; overlaid by pickle if saved
547
+ d["hash"] = None
548
+ insts = getattr(cls, "_instances", None)
549
+ if insts is not None:
550
+ try:
551
+ insts.add(obj)
552
+ except Exception:
553
+ pass
554
+ if _collect is not None:
555
+ _collect.append(obj)
556
+ return obj
557
+
558
+
559
+ # ────────────────────────────────────────────────────────────────────────────
560
+ # enum-by-name + callable-by-reference (refactor-safe)
561
+ # ────────────────────────────────────────────────────────────────────────────
562
+ def _enum_ref(e):
563
+ """Reduce an enum member by NAME against its by-reference class.
564
+
565
+ The class object itself is pickled by reference (resolved through find_class,
566
+ same path as every other class) so its IDENTITY is preserved — avoiding the
567
+ dual-module-identity mismatch get_enum_value's string re-resolution caused.
568
+ Name-first (values churn) with a value fallback for renamed members."""
569
+ cls = type(e)
570
+ v = e.value if isinstance(e.value, (int, float, str, bool, type(None))) else None
571
+ return (_resolve_enum, (cls, e.name, v))
572
+
573
+
574
+ def _resolve_enum(cls, name, value):
575
+ if getattr(cls, "__missing_saved_path__", None):
576
+ obj = _reconstruct(cls)
577
+ obj.name, obj.value = name, value
578
+ obj.__missing_enum__ = True
579
+ return obj
580
+ try:
581
+ return cls[name] # name-first, cls identity preserved
582
+ except KeyError:
583
+ if value is not None:
584
+ try:
585
+ return cls(value) # value fallback (renamed member)
586
+ except ValueError:
587
+ pass
588
+ # Both lookups failed (member renamed AND value remapped/dropped). Current
589
+ # system also degrades to None here - but make it VISIBLE (H2) so silent
590
+ # field loss is diagnosable. Migration contract: keep deleted enum names as
591
+ # stub aliases for back-compat.
592
+ log.warning("load_save_v2: enum %s has no member %r (value=%r) — loading as None",
593
+ getattr(cls, "__qualname__", cls), name, value)
594
+ return None
595
+
596
+
597
+ def _callable_ref(fn):
598
+ """A function/handle pickle can't take by plain reference -> dict_conversion's
599
+ resolvable marker (handles render-func registry handles + module refs)."""
600
+ return (_resolve_callable, (DictConversion.serialize_callable(fn),))
601
+
602
+
603
+ def _resolve_callable(ref):
604
+ if ref is None:
605
+ return None
606
+ return DictConversion.resolve_callable(ref)
607
+
608
+
609
+ def _needs_callable_ref(fn):
610
+ """True only when pickle's default save_global would FAIL for this function:
611
+ a lambda/<locals>, or — the hotswap case — the live object is no longer the
612
+ same object as module.qualname (a recompiled @render_func). A NORMAL function
613
+ whose module.qualname IS itself must return False, so it's saved by reference
614
+ (save_global), NOT via a serialize_callable reduce. (Routing every function
615
+ through serialize_callable is infinite recursion: the reduce's own callable
616
+ `_resolve_callable` is itself a function that would route through
617
+ serialize_callable, forever.)"""
618
+ qn = getattr(fn, "__qualname__", "") or ""
619
+ mod = getattr(fn, "__module__", None)
620
+ if not mod or "<locals>" in qn or "<lambda>" in qn:
621
+ return True
622
+ m = sys.modules.get(mod)
623
+ if m is None:
624
+ return False # not imported here; let save_global import + resolve it
625
+ target = m
626
+ try:
627
+ for part in qn.split("."):
628
+ target = getattr(target, part)
629
+ except AttributeError:
630
+ return True # not findable by qualname -> save_global would fail
631
+ return target is not fn # identity mismatch (hotswapped) -> need a name ref
632
+
633
+
634
+ # ────────────────────────────────────────────────────────────────────────────
635
+ # Pickler / Unpickler
636
+ # ────────────────────────────────────────────────────────────────────────────
637
+ class _PicklerOverrides:
638
+ # Shared by the C pickler (fast) and the pure-Python pickler (deep-chain safe).
639
+ def __init__(self, *a, excluded=None, **k):
640
+ super().__init__(*a, **k)
641
+ # caller-level field-level drops (the studio's read-only list) - C1
642
+ self._excluded = frozenset(excluded) if excluded else ()
643
+
644
+ def persistent_id(self, obj):
645
+ # Positive rule mirroring to_dict: KEEP primitives, enums, enums,
646
+ # referenceable classes, callables, and our own DictConversion. DROP
647
+ # EVERYTHING ELSE to None. to_dict only ever deep-serializes
648
+ # DictConversion and lets all other object fall through to None... so a
649
+ # live root full of helper objects / C handles / weakref proxies / etc
650
+ # serializes cleanly without per-type whack-a-mole.
651
+ if obj is None or isinstance(obj, (bool, int, float, complex, str, bytes, bytearray)):
652
+ return None
653
+ if isinstance(obj, (list, tuple, dict, set, frozenset)):
654
+ return None
655
+ if isinstance(obj, Enum):
656
+ return None
657
+ # External resources / weakrefs/proxies. Checked via type(obj) (proxy-safe:
658
+ # a weakref proxy masquerades as its referent through __class__) BEFORE the
659
+ # DictConversion check below, so a proxy-to-DictConversion is still dropped.
660
+ if _is_external(obj):
661
+ return "DROP"
662
+ if isinstance(obj, type):
663
+ if getattr(obj, "__missing_saved_path__", None):
664
+ return None
665
+ return "DROP" if _is_unpicklable_class(obj) else None
666
+ # Real functions/methods FIRST - kept (reducer_override / save_global by ref).
667
+ # MUST precede the unpicklable-class check: type(a_function) is the C type
668
+ # `function`, not exposed as builtins.function, so that check would wrongly
669
+ # flag every function (incl. our own reduce callables) -> infinite-recursion
670
+ # /'NoneType not type'. So whitelist genuine functions here.
671
+ if isinstance(obj, (types.FunctionType, types.BuiltinFunctionType,
672
+ types.MethodType)):
673
+ return None
674
+ # ANY object whose class can't be referenced by qualname can't be
675
+ # reconstructed -> drop it. Crucially this is BEFORE the callable-keep below,
676
+ # because bubbling converter dicts are CALLABLE (they have __call__) yet
677
+ # their class is dynamic: without this check'd reach pickle's default NEWOBJ
678
+ # reduction, whose class arg then drops to None -> "NEWOBJ class argument
679
+ # must be a type, not NoneType" on load.
680
+ if getattr(type(obj), "__missing_saved_path__", None):
681
+ return None
682
+ if _is_unpicklable_class(type(obj)):
683
+ return "DROP"
684
+ # Other callables whose class IS referenceable (e.g. _LazyRenderFunc) -> keep.
685
+ if callable(obj):
686
+ return None
687
+ if isinstance(obj, DictConversion):
688
+ # dlt_count prune (to_dict parity): any object whose delete-countdown has
689
+ # reached <= 0 (an unused draw_state - used states are reset to
690
+ # save_draw_state_for each frame) is DROPPED, so draw_state_registry
691
+ # doesn't grow unbounded. Its registry slot becomes None on load and the
692
+ # studio's draw_state_registry cleanup removes it.
693
+ dc = getattr(obj, "dlt_count", None)
694
+ if type(dc) is int and dc <= 0:
695
+ return "DROP"
696
+ return None # -> generic reduce in reducer_override
697
+ # numpy scalars (np.bool_/np.int_ that AREN'T isinstance of a Python
698
+ # primitive) -> kept so reducer_override converts them to a Python primitive.
699
+ ng = _numpy_generic()
700
+ if ng is not None and isinstance(obj, ng):
701
+ return None
702
+ return "DROP" # any other type -> stub to None (to_dict parity)
703
+
704
+ def reducer_override(self, obj):
705
+ if isinstance(obj, type) and getattr(obj, "__missing_saved_path__", None):
706
+ return (restore_saved_class, (obj.__missing_saved_path__,))
707
+ if (getattr(type(obj), "__missing_saved_path__", None)
708
+ and getattr(obj, "__missing_enum__", False)):
709
+ return (_resolve_enum, (type(obj), obj.name, obj.value))
710
+ # enums BY NAME
711
+ if isinstance(obj, Enum):
712
+ return _enum_ref(obj)
713
+ # Functions/methods: ALWAYS reference by name (serialize_callable), never
714
+ # by pickle's default save_global. save_global does an IDENTITY check
715
+ # (the live object must equal module.qualname), which FAILS for any
716
+ # hotswapped @render_func - the live function in the graph is a stale
717
+ # object while the module attribute points to the recompiled one
718
+ # ("not the same object as ...text_editor.draw_text"). Name resolution
719
+ # via resolve_callable re-binds to the CURRENT object, surviving hotswap.
720
+ if isinstance(obj, (types.FunctionType, types.BuiltinFunctionType,
721
+ types.MethodType)):
722
+ if _needs_callable_ref(obj): # only when save_global fails
723
+ ref = DictConversion.serialize_callable(obj)
724
+ if ref is not None:
725
+ return (_resolve_callable, (ref,))
726
+ return NotImplemented # normal fn -> save_global (no recursion)
727
+ # Container SUBCLASSES (RenderHost / _BubblingDict are dict subclasses) ->
728
+ # serialize as the PLAIN container plus CONTENTS, mirroring to_dict
729
+ # (which recurses any dict/list/set as a plain one, dropping the subclass
730
+ # type). pickle's DEFAULT for a container subclass is NEWOBJ(our_type),
731
+ # which crashes when the type is DYNAMIC - it gets dropped to None ->
732
+ # "NEWOBJ class argument must be a type, not NoneType". Items are pickled
733
+ # recursively, so nested DictConversions/primitives work. (DictConversion
734
+ # is skipped so its own reducer still runs.)
735
+ if not isinstance(obj, DictConversion):
736
+ t = type(obj)
737
+ if t is not dict and isinstance(obj, dict):
738
+ return (dict, (), None, None, iter(list(obj.items())))
739
+ if t is not list and isinstance(obj, list):
740
+ return (list, (), None, iter(list(obj)), None)
741
+ if t is not set and isinstance(obj, set):
742
+ return (set, (list(obj),))
743
+ if t is not frozenset and isinstance(obj, frozenset):
744
+ return (frozenset, (list(obj),))
745
+ # Any OTHER callable that isn't a class (e.g. _LazyRenderFunc registry
746
+ # handles, render_func wrappers): decide by whether serialize_callable can
747
+ # produce a resolvable marker, not just a class-name substring (M1). If it
748
+ # can't be referenced, fall through (pickle will error loudly, which is
749
+ # correct - we don't silently drop any unknown callable).
750
+ if callable(obj) and not isinstance(obj, type) and type(obj).__module__ != "builtins":
751
+ if DictConversion.serialize_callable(obj) is not None:
752
+ return _callable_ref(obj)
753
+ # our domain objects -> cycles-safe reduce with filtered state (3-tuple
754
+ # form so pickle memoizes the instance before applying state -> cycles OK).
755
+ # Restricted to DictConversion (persistent_id has already dropped every
756
+ # other object to None), mirroring to_dict's "only DictConversion is
757
+ # deep-serialized" rule.
758
+ if isinstance(obj, DictConversion):
759
+ return (_reconstruct, (type(obj),), _save_state(obj, self._excluded))
760
+ # numpy scalars (np.float64 isinstance float, np.int64 isinstance int, ...)
761
+ # -> a PYTHON primitive, mirroring to_dict (its str/eval codec does the
762
+ # same). Their native pickle reduce is (scalar, (numpy.dtype, bytes)), and
763
+ # the catch-all drops the dtype to None -> "scalar() argument 1 must be
764
+ # numpy.dtype, not None" on load. obj.item() yields the Python value.
765
+ ng = _numpy_generic()
766
+ if ng is not None and isinstance(obj, ng):
767
+ try:
768
+ v = obj.item()
769
+ return (type(v), (v,))
770
+ except Exception:
771
+ pass
772
+ return NotImplemented
773
+
774
+
775
+ # Concrete picklers: C (fast) for the common case, pure-Python for deep chains
776
+ # (its recursion is plain Python recursion, controllable by setrecursionlimit).
777
+ class LSDPickler(_PicklerOverrides, pickle.Pickler):
778
+ pass
779
+
780
+
781
+ class LSDPicklerPy(_PicklerOverrides, pickle._Pickler):
782
+ pass
783
+
784
+
785
+ class _UnpicklerOverrides:
786
+ def persistent_load(self, pid):
787
+ if pid == "DROP":
788
+ return None
789
+ raise pickle.UnpicklingError(f"unknown persistent id {pid!r}")
790
+
791
+ def find_class(self, module, name):
792
+ from meltygui.core.module_names import canonical_name
793
+ old_path = f"{module}.{name}"
794
+ path = canonical_name(old_path)
795
+ if path != old_path and path.endswith("." + name):
796
+ module = path[:-(len(name) + 1)]
797
+ else:
798
+ module = canonical_name(module)
799
+ # Legacy-pickle recovery: a numpy scalar saved before the ng-conversion fix
800
+ # has a (numpy...scalar, (dtype, ...)) reduce that would crash. Hand it to a
801
+ # tolerant reconstructor (recovers as 0.0). New saves don't use numpy scalar.
802
+ if name == "scalar" and "numpy" in module:
803
+ return _recover_numpy_scalar
804
+ # Try the normal path first (fast, correct when nothing moved).
805
+ try:
806
+ return super().find_class(module, name)
807
+ except Exception:
808
+ pass
809
+ # Fuzzy fallback via ClassUtility - handles moved / nested / re-rooted
810
+ # classes the same way instantiate_from_class_path does.
811
+ try:
812
+ ClassUtility().initialize_class_names("meltygui")
813
+ except Exception:
814
+ pass
815
+ try:
816
+ obj = DictConversion.instantiate_from_class_path(f"{module}.{name}")
817
+ except Exception as error:
818
+ # Importing the current source can fail (including SyntaxErrors) even
819
+ # though the saved graph is intact. The fuzzy retry is optional too.
820
+ log.warning("Cannot resolve saved class %s.%s: %s", module, name, error)
821
+ obj = None
822
+ if obj is not None:
823
+ return type(obj)
824
+ # A deleted class must not make the entire session unreadable. Preserve
825
+ # its fields and graph identity, including when this session is re-saved.
826
+ return missing_saved_class(f"{module}.{name}")
827
+
828
+
829
+ class LSDUnpickler(_UnpicklerOverrides, pickle.Unpickler):
830
+ pass
831
+
832
+
833
+ class LSDUnpicklerPy(_UnpicklerOverrides, pickle._Unpickler):
834
+ pass
835
+
836
+
837
+ # ────────────────────────────────────────────────────────────────────────────
838
+ # public API
839
+ # ────────────────────────────────────────────────────────────────────────────
840
+ import threading
841
+
842
+ # The live graph is far deeper than Python's default 1000 recursion limit
843
+ # (the studio's to_dict guards against up to 10000, and unlike to_dict - which
844
+ # flattens DictConversions into a flat hash-table - pickle recurses the full
845
+ # reference chain). Deep recursion needs BOTH a large Python limit and a large
846
+ # thread stack, so run save/load on a worker thread with a big stack.
847
+ _RECURSION_LIMIT = 1_000_000
848
+ _STACK_SIZE = 256 * 1024 * 1024 # worker stack for the rare deep-graph
849
+ # pure-Python fallback (only allocated then)
850
+
851
+
852
+ def _in_big_stack(fn):
853
+ """Run fn() on a thread with a large stack + high recursion limit, so deep
854
+ object graphs don't overflow the (small) caller-thread stack."""
855
+ box = {}
856
+
857
+ def run():
858
+ old = sys.getrecursionlimit()
859
+ sys.setrecursionlimit(_RECURSION_LIMIT)
860
+ try:
861
+ box["v"] = fn()
862
+ except BaseException as e: # propagate to the caller thread
863
+ box["e"] = e
864
+ finally:
865
+ sys.setrecursionlimit(old)
866
+
867
+ prev = None
868
+ try:
869
+ prev = threading.stack_size(_STACK_SIZE)
870
+ except (ValueError, RuntimeError):
871
+ prev = None
872
+ t = threading.Thread(target=run, name="load_save_v2")
873
+ t.start()
874
+ t.join()
875
+ if prev is not None:
876
+ try:
877
+ threading.stack_size(prev)
878
+ except Exception:
879
+ pass
880
+ if "e" in box:
881
+ raise box["e"]
882
+ return box.get("v")
883
+
884
+
885
+ def _dump_with(PicklerCls, obj, excluded):
886
+ buf = io.BytesIO()
887
+ PicklerCls(buf, protocol=5, excluded=excluded).dump(obj)
888
+ return buf.getvalue()
889
+
890
+
891
+ def dumps(obj, excluded=None):
892
+ """Pickle `obj` to bytes. `excluded` is the caller-level field-name drop list
893
+ (pass the same list the studio gives to_dict, e.g. STUDIO_SAVE_EXCLUDED) so
894
+ runtime-only buffers/caches aren't serialized (C1).
895
+
896
+ Cycles are fine (pickle's memo handles them). Common case: the fast C pickler
897
+ runs DIRECTLY on the calling thread — it self-limits at Python 3.12's
898
+ C-recursion guard and raises a CATCHABLE RecursionError (no segfault). Only a
899
+ genuinely deep reference chain falls back to the pure-Python pickler on a
900
+ big-stack thread."""
901
+ try:
902
+ return _dump_with(LSDPickler, obj, excluded)
903
+ except RecursionError:
904
+ log.warning("load_save_v2: deep graph exceeded C pickler limit — "
905
+ "falling back to pure-Python pickler")
906
+ return _in_big_stack(lambda: _dump_with(LSDPicklerPy, obj, excluded))
907
+
908
+
909
+ def loads(data, *, vis=None, root=None, run_on_load=True):
910
+ # The pickle format is stable, so the C unpickler reads either pickler's bytes;
911
+ # fall back to the pure-Python unpickler only if depth overflows the C one.
912
+ # _collect gathers every reconstructed DictConversion so _post_load needn't walk.
913
+ global _collect
914
+ _collect = []
915
+ try:
916
+ try:
917
+ obj = LSDUnpickler(io.BytesIO(data)).load()
918
+ except RecursionError:
919
+ log.warning("load_save_v2: deep graph exceeded C unpickler limit — "
920
+ "falling back to pure-Python unpickler")
921
+ obj = _in_big_stack(lambda: LSDUnpicklerPy(io.BytesIO(data)).load())
922
+ collected = _collect
923
+ finally:
924
+ _collect = None
925
+ if run_on_load:
926
+ _post_load(obj, collected, vis=vis, root=root if root is not None else obj)
927
+ return obj
928
+
929
+
930
+ # ---------------------------------------------------------------------------
931
+ # Session path selection (`py latent_descent.py --load_from X --save_to Y`)
932
+ # ---------------------------------------------------------------------------
933
+
934
+ LOAD_FROM_ENV = "LSD_LOAD_FROM" # env fallbacks for hosts that call main()
935
+ SAVE_TO_ENV = "LSD_SAVE_TO" # ignore argv (the launcher)
936
+
937
+
938
+ def pkl_path_for(path):
939
+ """The pickle a session file name denotes: `custom.ini` -> `custom.pkl`,
940
+ `x.pkl` -> itself, an extension-less backup name -> name + `.pkl`
941
+ (splitext would mangle the dotted backup names)."""
942
+ path = str(path)
943
+ if path.endswith(".pkl"):
944
+ return path
945
+ if path.endswith(".ini"):
946
+ return path[:-4] + ".pkl"
947
+ return path + ".pkl"
948
+
949
+
950
+ def ini_sibling_for(path):
951
+ """The legacy `.ini` (custom_data) that rides beside a session pickle."""
952
+ path = str(path)
953
+ if path.endswith(".ini"):
954
+ return path
955
+ if path.endswith(".pkl"):
956
+ return path[:-4] + ".ini"
957
+ return path + ".ini"
958
+
959
+
960
+ def resolve_session_paths(argv=None, environ=None, root=None):
961
+ """(load_from, save_to) absolute paths, or None where nothing was asked.
962
+
963
+ `--load_from FILE` / `--save_to FILE` on argv win; the `LSD_LOAD_FROM` /
964
+ `LSD_SAVE_TO` env vars are the fallback. Relative paths resolve against
965
+ `root` (the repo root, the cwd of a direct launch). Unknown argv entries
966
+ are left alone (the model flag is parsed elsewhere with parse_known_args).
967
+ """
968
+ import argparse
969
+ argv = list(sys.argv[1:] if argv is None else argv)
970
+ environ = os.environ if environ is None else environ
971
+ parser = argparse.ArgumentParser(add_help=False)
972
+ parser.add_argument("--load_from", "--load-from", dest="load_from", default=None)
973
+ parser.add_argument("--save_to", "--save-to", dest="save_to", default=None)
974
+ args, _unknown = parser.parse_known_args(argv)
975
+ load_from = args.load_from or environ.get(LOAD_FROM_ENV) or None
976
+ save_to = args.save_to or environ.get(SAVE_TO_ENV) or None
977
+ root = os.getcwd() if root is None else str(root)
978
+
979
+ def _abs(value):
980
+ if not value:
981
+ return None
982
+ value = os.path.expanduser(str(value))
983
+ return value if os.path.isabs(value) else os.path.join(root, value)
984
+
985
+ return _abs(load_from), _abs(save_to)
986
+
987
+
988
+ def save(obj, path, excluded=None):
989
+ """Atomic save: serialize FULLY first (so a dump failure leaves the existing
990
+ pkl untouched — never a truncated/empty file), then temp-write + rename so a
991
+ reader never sees a half-written pkl (which would EOFError on load)."""
992
+ data = dumps(obj, excluded=excluded) # may raise -> leave untouched
993
+ tmp = f"{path}.tmp"
994
+ with open(tmp, "wb") as f:
995
+ f.write(data)
996
+ f.flush()
997
+ os.fsync(f.fileno())
998
+ os.replace(tmp, path) # atomic on POSIX
999
+
1000
+
1001
+ def load(path, *, vis=None, run_on_load=True):
1002
+ with open(path, "rb") as f:
1003
+ return loads(f.read(), vis=vis, run_on_load=run_on_load)
1004
+
1005
+
1006
+ def _post_load(graph_root, collected, *, vis=None, root=None):
1007
+ """Replicate from_dict's final pass (which __setstate__ can't, lacking context):
1008
+ 1. collect every DictConversion into root._instantiated_objects (id->inst),
1009
+ 2. fire on_load(vis, root) on each (mirrors dict_conversion 207-221).
1010
+
1011
+ `collected` is every DictConversion reconstructed during the load (gathered in
1012
+ _reconstruct), so we DON'T re-walk the graph — that walk pushed every primitive
1013
+ __dict__ value onto a stack (millions of list.pop/id() calls). Studio-specific
1014
+ fixups (_parent_tensor_frame rebind, save_config pruning, draw_state_registry
1015
+ validation) belong in the studio swap, not here."""
1016
+ if collected is None: # e.g. plain-pickle path with no collect
1017
+ collected = [graph_root] if isinstance(graph_root, DictConversion) else []
1018
+ instantiated = {getattr(o, "id", id(o)): o for o in collected}
1019
+
1020
+ if isinstance(root, DictConversion):
1021
+ try:
1022
+ object.__setattr__(root, "_instantiated_objects", instantiated)
1023
+ except Exception:
1024
+ pass
1025
+
1026
+ for inst in collected:
1027
+ cb = getattr(inst, "on_load", None)
1028
+ if callable(cb):
1029
+ try:
1030
+ cb(vis=vis, root=root)
1031
+ except Exception:
1032
+ pass