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.
- meltygui/__init__.py +107 -0
- meltygui/accounts/__init__.py +0 -0
- meltygui/accounts/internet_accounts.py +1355 -0
- meltygui/chat/__init__.py +91 -0
- meltygui/chat/activity.py +75 -0
- meltygui/chat/backends.py +36 -0
- meltygui/chat/chat_interface.py +732 -0
- meltygui/chat/chat_proxy.py +352 -0
- meltygui/chat/codex_proxy.py +592 -0
- meltygui/chat/codex_settings.py +100 -0
- meltygui/chat/codex_transport.py +60 -0
- meltygui/chat/command_parser.py +204 -0
- meltygui/chat/images.py +227 -0
- meltygui/chat/messages.py +417 -0
- meltygui/chat/metadata.py +139 -0
- meltygui/chat/writer_locks.py +64 -0
- meltygui/code/__init__.py +0 -0
- meltygui/code/basic_converters.py +533 -0
- meltygui/code/chain_converters.py +2111 -0
- meltygui/code/code_checks.py +2209 -0
- meltygui/code/core_syntax.py +1430 -0
- meltygui/code/file_converters.py +1933 -0
- meltygui/code/fileref.py +702 -0
- meltygui/code/hotswap_guard.py +144 -0
- meltygui/code/libcst_conversion.py +9724 -0
- meltygui/code/live_instrument.py +392 -0
- meltygui/code/live_view.py +2490 -0
- meltygui/code/melty_scan.py +2684 -0
- meltygui/code/new_codecs.py +1255 -0
- meltygui/code/new_converters.py +3017 -0
- meltygui/code/project_code.py +278 -0
- meltygui/code/source_context.py +63 -0
- meltygui/code/symbol_roster.py +1588 -0
- meltygui/code/syntax_check.py +34 -0
- meltygui/code/syntax_check_worker.py +114 -0
- meltygui/completion/__init__.py +0 -0
- meltygui/completion/fim.py +1232 -0
- meltygui/completion/fim_context.py +481 -0
- meltygui/completion/providers/__init__.py +0 -0
- meltygui/completion/providers/anthropic_oauth.py +446 -0
- meltygui/completion/providers/anthropic_requests.py +69 -0
- meltygui/completion/providers/claude.py +203 -0
- meltygui/completion/providers/claude_usage.py +499 -0
- meltygui/completion/providers/codex_accounts.py +180 -0
- meltygui/completion/providers/copilot.py +617 -0
- meltygui/completion/providers/oauth_popup.py +220 -0
- meltygui/completion/providers/ollama.py +320 -0
- meltygui/completion/providers/profiles.py +23 -0
- meltygui/core/README.md +88 -0
- meltygui/core/__init__.py +1 -0
- meltygui/core/automation/__init__.py +1 -0
- meltygui/core/automation/action_core.py +153 -0
- meltygui/core/automation/collection_action.py +45 -0
- meltygui/core/automation/mcp_eval.py +167 -0
- meltygui/core/automation/mcp_hotswap.py +107 -0
- meltygui/core/automation/mcp_query.py +552 -0
- meltygui/core/automation/mcp_server.py +657 -0
- meltygui/core/automation/orchestration_core.py +2636 -0
- meltygui/core/automation/query_core.py +124 -0
- meltygui/core/automation/search_core.py +26 -0
- meltygui/core/automation/selector_core.py +340 -0
- meltygui/core/automation/value_core.py +1752 -0
- meltygui/core/cache/__init__.py +1 -0
- meltygui/core/cache/cache_diagnostics.py +0 -0
- meltygui/core/cache/invalidation_decoration.py +153 -0
- meltygui/core/cache/invalidation_tracker.py +43 -0
- meltygui/core/cache/tile_cache.py +5968 -0
- meltygui/core/conversion/__init__.py +1 -0
- meltygui/core/conversion/bubbling.py +599 -0
- meltygui/core/conversion/cache_tree.py +188 -0
- meltygui/core/conversion/chain.py +113 -0
- meltygui/core/conversion/converter_register.py +145 -0
- meltygui/core/conversion/data_decoration.py +67 -0
- meltygui/core/conversion/dict_conversion.py +1815 -0
- meltygui/core/conversion/dict_conversion_util.py +177 -0
- meltygui/core/conversion/dynamic_obj.py +89 -0
- meltygui/core/conversion/graph_compare.py +183 -0
- meltygui/core/conversion/load_save_v2.py +1032 -0
- meltygui/core/conversion/missing_saved_class.py +39 -0
- meltygui/core/conversion/path_finder.py +606 -0
- meltygui/core/conversion/render_host.py +1083 -0
- meltygui/core/core_render.py +6537 -0
- meltygui/core/definition_hotswap.py +231 -0
- meltygui/core/diagnostics/__init__.py +1 -0
- meltygui/core/diagnostics/attribute_churn.py +38 -0
- meltygui/core/diagnostics/fps_counter.py +39 -0
- meltygui/core/diagnostics/gpu_frame_timer.py +103 -0
- meltygui/core/diagnostics/inspection_core.py +169 -0
- meltygui/core/diagnostics/monitor_core.py +105 -0
- meltygui/core/diagnostics/notifications.py +706 -0
- meltygui/core/diagnostics/perf_trace.py +281 -0
- meltygui/core/diagnostics/profile_decoration.py +88 -0
- meltygui/core/diagnostics/resize_trace.py +62 -0
- meltygui/core/diagnostics/screenshot_core.py +247 -0
- meltygui/core/diagnostics/session_status.py +98 -0
- meltygui/core/diagnostics/trace_core.py +445 -0
- meltygui/core/files/__init__.py +1 -0
- meltygui/core/files/file_core.py +208 -0
- meltygui/core/files/file_explorer_core.py +104 -0
- meltygui/core/files/file_tree_core.py +198 -0
- meltygui/core/files/file_watch_core.py +43 -0
- meltygui/core/files/import_graph_core.py +43 -0
- meltygui/core/files/metadata_core.py +51 -0
- meltygui/core/graphics/__init__.py +1 -0
- meltygui/core/graphics/cuda_context_core.py +166 -0
- meltygui/core/graphics/cuda_interop_core.py +136 -0
- meltygui/core/graphics/cuda_kernel_core.py +91 -0
- meltygui/core/graphics/framebuffer_recorder.py +337 -0
- meltygui/core/graphics/gl_state.py +658 -0
- meltygui/core/graphics/lut_core.py +52 -0
- meltygui/core/graphics/overlay_renderer.py +984 -0
- meltygui/core/graphics/scene_target.py +180 -0
- meltygui/core/graphics/screenshot.py +439 -0
- meltygui/core/graphics/shader_func.py +478 -0
- meltygui/core/graphics/tensor_core.py +45 -0
- meltygui/core/graphics/text_texture.py +329 -0
- meltygui/core/graphics/wayland_color.py +635 -0
- meltygui/core/input/__init__.py +1 -0
- meltygui/core/input/collision.py +165 -0
- meltygui/core/input/drag_drop_core.py +1525 -0
- meltygui/core/input/hypr_left_drag.py +323 -0
- meltygui/core/input/input_core.py +245 -0
- meltygui/core/input/input_handler.py +1101 -0
- meltygui/core/input/mouse_cursor.py +355 -0
- meltygui/core/input/pynput_backend.py +1054 -0
- meltygui/core/input/space_mouse.py +338 -0
- meltygui/core/input/touchpad_backend.py +393 -0
- meltygui/core/input/view_selection.py +177 -0
- meltygui/core/layout/__init__.py +1 -0
- meltygui/core/layout/column_core.py +2153 -0
- meltygui/core/layout/cursor_core.py +161 -0
- meltygui/core/layout/dropdown_core.py +278 -0
- meltygui/core/layout/edge_constraints.py +155 -0
- meltygui/core/layout/grid_core.py +117 -0
- meltygui/core/layout/header_core.py +23 -0
- meltygui/core/layout/header_runtime.py +67 -0
- meltygui/core/layout/layout_core.py +87 -0
- meltygui/core/layout/tile_manager_core.py +591 -0
- meltygui/core/melty.py +6726 -0
- meltygui/core/module_map.json +896 -0
- meltygui/core/module_names.py +19 -0
- meltygui/core/rendering/__init__.py +1 -0
- meltygui/core/rendering/core_decoration.py +431 -0
- meltygui/core/rendering/core_render_helpers.py +328 -0
- meltygui/core/rendering/func_metadata.py +398 -0
- meltygui/core/rendering/mode.py +818 -0
- meltygui/core/rendering/mode_defaults.py +41 -0
- meltygui/core/rendering/modes.py +136 -0
- meltygui/core/rendering/parameter_core.py +1665 -0
- meltygui/core/rendering/render_dispatch.py +1891 -0
- meltygui/core/rendering/render_funcs.py +273 -0
- meltygui/core/rendering/shaped.py +312 -0
- meltygui/core/rendering/window_decoration.py +25 -0
- meltygui/core/runtime/__init__.py +1 -0
- meltygui/core/runtime/app.py +735 -0
- meltygui/core/runtime/app_session.py +140 -0
- meltygui/core/runtime/background.py +564 -0
- meltygui/core/runtime/extensions.py +53 -0
- meltygui/core/runtime/gc_manager.py +1163 -0
- meltygui/core/runtime/lifecycle.py +21 -0
- meltygui/core/runtime/paths.py +27 -0
- meltygui/core/runtime/settings.py +14 -0
- meltygui/core/runtime/singleton.py +16 -0
- meltygui/core/runtime/thread_safe_bool.py +24 -0
- meltygui/core/runtime/thread_signal.py +30 -0
- meltygui/core/runtime/toggles.py +3115 -0
- meltygui/core/services/__init__.py +1 -0
- meltygui/core/services/account_core.py +10 -0
- meltygui/core/services/chat_core.py +19 -0
- meltygui/core/services/claude_terminal_core.py +346 -0
- meltygui/core/services/terminal_core.py +458 -0
- meltygui/core/services/terminal_runtime.py +78 -0
- meltygui/core/styling/__init__.py +1 -0
- meltygui/core/styling/color_core.py +46 -0
- meltygui/core/styling/fonts.py +639 -0
- meltygui/core/styling/global_style.py +338 -0
- meltygui/core/styling/style.py +198 -0
- meltygui/core/styling/style_core.py +522 -0
- meltygui/core/styling/warm_start.py +149 -0
- meltygui/core/windowing/__init__.py +1 -0
- meltygui/core/windowing/backends/PYIMGUI_LICENSE +28 -0
- meltygui/core/windowing/backends/__init__.py +1 -0
- meltygui/core/windowing/backends/imgui_renderer.py +138 -0
- meltygui/core/windowing/backends/native_wayland.py +850 -0
- meltygui/core/windowing/backends/protocols/xdg-decoration-unstable-v1.xml +156 -0
- meltygui/core/windowing/backends/protocols/xdg-shell.xml +1420 -0
- meltygui/core/windowing/backends/wayland_protocol.py +101 -0
- meltygui/core/windowing/dock_core.py +182 -0
- meltygui/core/windowing/frame_geometry.py +42 -0
- meltygui/core/windowing/geometry_feed.py +864 -0
- meltygui/core/windowing/glfw_utils.py +1343 -0
- meltygui/core/windowing/os_frame.py +1552 -0
- meltygui/core/windowing/surface.py +643 -0
- meltygui/core/windowing/titlebar.py +1560 -0
- meltygui/core/windowing/titlebar_buttons.py +281 -0
- meltygui/core/windowing/wayland_move.py +932 -0
- meltygui/core/windowing/window_api.py +62 -0
- meltygui/core/windowing/window_constants.py +339 -0
- meltygui/core/windowing/window_visibility.py +162 -0
- meltygui/debug/__init__.py +0 -0
- meltygui/debug/app_view_utils.py +9 -0
- meltygui/editor/__init__.py +0 -0
- meltygui/editor/bash_syntax.py +30 -0
- meltygui/editor/code_line_fast.py +152 -0
- meltygui/editor/diff.py +139 -0
- meltygui/editor/external_changes.py +159 -0
- meltygui/editor/file_header.py +41 -0
- meltygui/editor/live_usage.py +169 -0
- meltygui/editor/live_view_views.py +1803 -0
- meltygui/editor/pending_save.py +1351 -0
- meltygui/editor/roster_tints.py +547 -0
- meltygui/editor/source_preview.py +16 -0
- meltygui/editor/source_tools.py +10 -0
- meltygui/editor/source_ui.py +70 -0
- meltygui/editor/spell_check.py +77 -0
- meltygui/editor/text_editor.py +9076 -0
- meltygui/editor/usage_picker.py +538 -0
- meltygui/events/__init__.py +0 -0
- meltygui/events/example.py +118 -0
- meltygui/examples/__init__.py +0 -0
- meltygui/examples/columns_demo.py +33 -0
- meltygui/examples/columns_window_demo.py +60 -0
- meltygui/examples/context_menu_demo.py +24 -0
- meltygui/examples/context_menu_window_demo.py +49 -0
- meltygui/examples/gui_playground.py +127 -0
- meltygui/examples/live_view_playground.py +256 -0
- meltygui/examples/lora.py +43 -0
- meltygui/examples/lora_data.py +59 -0
- meltygui/examples/lora_policies.py +67 -0
- meltygui/examples/mode_demo.py +49 -0
- meltygui/examples/modifies_demo.py +119 -0
- meltygui/examples/scalar_policies.py +60 -0
- meltygui/examples/style_layouts.py +149 -0
- meltygui/examples/tile_manager_demo.py +74 -0
- meltygui/examples/tint_demo.py +160 -0
- meltygui/examples/tint_functions.py +56 -0
- meltygui/examples/trace_demo.py +88 -0
- meltygui/examples/two_windows.py +36 -0
- meltygui/files/__init__.py +0 -0
- meltygui/files/fast_file_explorer.py +439 -0
- meltygui/gnome_extension/lsd-window-geometry@latent-descent/extension.js +237 -0
- meltygui/gnome_extension/lsd-window-geometry@latent-descent/lsd-window-geometry@latent-descent.iml +9 -0
- meltygui/gnome_extension/lsd-window-geometry@latent-descent/metadata.json +7 -0
- meltygui/graphics/__init__.py +6 -0
- meltygui/graphics/base.py +85 -0
- meltygui/graphics/examples.py +507 -0
- meltygui/graphics/executor.py +520 -0
- meltygui/graphics/filter.py +667 -0
- meltygui/graphics/filter.pyi +856 -0
- meltygui/graphics/generate_stubs.py +22 -0
- meltygui/graphics/registry.py +203 -0
- meltygui/graphics/shader_compiler.py +155 -0
- meltygui/graphics/shaders.py +1302 -0
- meltygui/graphics/stub_generator.py +250 -0
- meltygui/graphics/texture_manager.py +170 -0
- meltygui/graphics/texture_min_max.py +295 -0
- meltygui/hdr_color.py +757 -0
- meltygui/image_load.py +308 -0
- meltygui/model/__init__.py +1 -0
- meltygui/model/account_model.py +142 -0
- meltygui/model/camera_model.py +159 -0
- meltygui/model/chat_model.py +86 -0
- meltygui/model/code_model.py +62 -0
- meltygui/model/code_proxy_model.py +876 -0
- meltygui/model/collection_model.py +41 -0
- meltygui/model/color_model.py +127 -0
- meltygui/model/cuda_tensor_model.py +36 -0
- meltygui/model/cuda_texture_model.py +149 -0
- meltygui/model/dropdown_model.py +137 -0
- meltygui/model/file_metadata_model.py +73 -0
- meltygui/model/file_model.py +289 -0
- meltygui/model/format_model.py +390 -0
- meltygui/model/graph_model.py +98 -0
- meltygui/model/icon_model.py +1024 -0
- meltygui/model/import_graph_model.py +468 -0
- meltygui/model/layout_model.py +15 -0
- meltygui/model/lut_model.py +266 -0
- meltygui/model/search_model.py +173 -0
- meltygui/model/tensor_model.py +402 -0
- meltygui/model/terminal_model.py +329 -0
- meltygui/model/texture_model.py +146 -0
- meltygui/model/tile_model.py +24 -0
- meltygui/model/trace_model.py +198 -0
- meltygui/model/trace_report_model.py +146 -0
- meltygui/models/__init__.py +0 -0
- meltygui/models/file_meta.py +613 -0
- meltygui/models/function_console.py +184 -0
- meltygui/models/orchestration.py +48 -0
- meltygui/pbr.py +1576 -0
- meltygui/png_unfilter.c +49 -0
- meltygui/resources/JetBrainsMono-Regular.ttf +0 -0
- meltygui/resources/THIRD_PARTY_NOTICES.md +21 -0
- meltygui/resources/dejavu/DejaVuSans-Bold.ttf +0 -0
- meltygui/resources/dejavu/DejaVuSans-ExtraLight.ttf +0 -0
- meltygui/resources/dejavu/DejaVuSans.ttf +0 -0
- meltygui/resources/dejavu/LICENSE.txt +78 -0
- meltygui/resources/fontawesome-LICENSE.txt +121 -0
- meltygui/resources/fontawesome-webfont.ttf +0 -0
- meltygui/resources/hdri/studio_small_09_1k.hdr +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-Bold.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraBold.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraLight.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-Light.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-Medium.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-SemiBold.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-Thin.ttf +0 -0
- meltygui/resources/jetbrains-weights/OFL.txt +93 -0
- meltygui/resources/jetbrains-weights/README.md +2 -0
- meltygui/state/__init__.py +0 -0
- meltygui/state/account_state.py +22 -0
- meltygui/state/animation_state.py +97 -0
- meltygui/state/annotation_state.py +22 -0
- meltygui/state/chat_state.py +36 -0
- meltygui/state/code_state.py +10 -0
- meltygui/state/core_enums.py +59 -0
- meltygui/state/core_markers.py +115 -0
- meltygui/state/core_undo.py +1075 -0
- meltygui/state/file_state.py +75 -0
- meltygui/state/graph_state.py +26 -0
- meltygui/state/inspection_state.py +47 -0
- meltygui/state/menu_state.py +10 -0
- meltygui/state/model_enums.py +11 -0
- meltygui/state/new_core_model.py +2394 -0
- meltygui/state/orchestration_state.py +14 -0
- meltygui/state/query_state.py +24 -0
- meltygui/state/tensor_state.py +10 -0
- meltygui/state/terminal_state.py +23 -0
- meltygui/state/trace_state.py +32 -0
- meltygui/state/voxel_state.py +12 -0
- meltygui/text_index.py +816 -0
- meltygui/utils/__init__.py +0 -0
- meltygui/utils/jump_to_code.py +344 -0
- meltygui/utils/pkl_inspect.py +90 -0
- meltygui/utils/render_utils.py +1419 -0
- meltygui/view/__init__.py +1 -0
- meltygui/view/account_view.py +524 -0
- meltygui/view/action_view.py +73 -0
- meltygui/view/chat_decoration_view.py +112 -0
- meltygui/view/chat_view.py +1703 -0
- meltygui/view/code_view.py +2677 -0
- meltygui/view/collection_view.py +1185 -0
- meltygui/view/color_view.py +824 -0
- meltygui/view/control_view.py +559 -0
- meltygui/view/decoration_view.py +439 -0
- meltygui/view/diagnostic_view.py +177 -0
- meltygui/view/dropdown_view.py +1082 -0
- meltygui/view/file_view.py +1599 -0
- meltygui/view/graph_cuda_view.py +105 -0
- meltygui/view/graph_view.py +882 -0
- meltygui/view/header_view.py +848 -0
- meltygui/view/input_view.py +238 -0
- meltygui/view/inspection_view.py +1783 -0
- meltygui/view/layout_view.py +596 -0
- meltygui/view/lut_view.py +45 -0
- meltygui/view/menu_view.py +212 -0
- meltygui/view/orchestration_view.py +658 -0
- meltygui/view/query_view.py +118 -0
- meltygui/view/search_view.py +342 -0
- meltygui/view/tab_view.py +258 -0
- meltygui/view/tensor_view.py +430 -0
- meltygui/view/terminal_view.py +355 -0
- meltygui/view/text_view.py +8031 -0
- meltygui/view/texture_view.py +480 -0
- meltygui/view/tile_view.py +38 -0
- meltygui/view/trace_view.py +923 -0
- meltygui/view/voxel_cuda_view.py +824 -0
- meltygui/view/voxel_view.py +1860 -0
- meltygui/view/window_view.py +111 -0
- meltygui-0.1.0.dist-info/METADATA +143 -0
- meltygui-0.1.0.dist-info/RECORD +372 -0
- meltygui-0.1.0.dist-info/WHEEL +4 -0
- 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
|