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