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
meltygui/code/fileref.py
ADDED
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Address + file-watched converter wrappers.
|
|
3
|
+
|
|
4
|
+
Each cache_id gets fully isolated state: its own mtime/size tracking,
|
|
5
|
+
its own cached results, its own original_data for dirty detection.
|
|
6
|
+
No shared state between views.
|
|
7
|
+
|
|
8
|
+
Cross-view change detection happens naturally — each view stats the
|
|
9
|
+
file independently, so one view's write shows up as a disk change
|
|
10
|
+
to the other view on its next load.
|
|
11
|
+
|
|
12
|
+
convert(my_func, dict, registry=Melty, apply=True, cache_id="view_1")
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import difflib
|
|
18
|
+
import functools
|
|
19
|
+
import hashlib
|
|
20
|
+
import sys
|
|
21
|
+
import types
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
import os
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any, Callable
|
|
26
|
+
import inspect
|
|
27
|
+
|
|
28
|
+
from meltygui.core.conversion.path_finder import Pending
|
|
29
|
+
from meltygui.core.rendering.core_decoration import defaults
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ╔══════════════════════════════════════════════════════════════════════════════╗
|
|
33
|
+
# ║ Library write guard ║
|
|
34
|
+
# ╚══════════════════════════════════════════════════════════════════════════════╝
|
|
35
|
+
# The editor resolves a class/function/module to its source FILE and edits a line
|
|
36
|
+
# span in place. Nothing stops `type(value)` from being a class that lives in a
|
|
37
|
+
# third-party package - and a wrong resolve once silently deleted a field from
|
|
38
|
+
# libcst's own whitespace.py, breaking the parser. We are an editor for THIS
|
|
39
|
+
# project's code, never for installed libraries, so every resolve/write checks
|
|
40
|
+
# this gate. Returning False makes resolve_address return None (editor displays
|
|
41
|
+
# "can't resolve") and makes the save functions skip the write - fail closed.
|
|
42
|
+
|
|
43
|
+
# Project root: this file is .../src/lsd/gl_gui/view/core_conversion/address.py
|
|
44
|
+
# → parents[5] is the repo root (the dir that contains `src/`).
|
|
45
|
+
from meltygui.core.runtime.paths import PACKAGE_ROOT
|
|
46
|
+
_PROJECT_ROOT = PACKAGE_ROOT.parent
|
|
47
|
+
|
|
48
|
+
# Every tree whose code the editor may resolve, edit and hotswap: this
|
|
49
|
+
# checkout itself and each meltygui APP's own project (registered by
|
|
50
|
+
# app.glfw_window for the window function's file, and by app.boot for
|
|
51
|
+
# the main module - an app outside this repo is otherwise "library source"
|
|
52
|
+
# to every view codec, so its `@glfw_window(tint=...)` never loads as an
|
|
53
|
+
# input source and a header tint property falls through to the draw_state,
|
|
54
|
+
# 09-12). Resolved paths, first registration defines the order.
|
|
55
|
+
_EDITABLE_ROOTS = ([_PROJECT_ROOT] if (_PROJECT_ROOT / ".git").exists()
|
|
56
|
+
and not {"site-packages", "dist-packages"} & set(_PROJECT_ROOT.parts) else [])
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def editable_roots():
|
|
60
|
+
"""The registered project roots (resolved Paths), the checkout first."""
|
|
61
|
+
return tuple(_EDITABLE_ROOTS)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def add_editable_root(path) -> Path | None:
|
|
65
|
+
"""Register a project tree as editable source — the directory `path`,
|
|
66
|
+
or the PROJECT ROOT of a file `path` (see project_root_of). Idempotent;
|
|
67
|
+
a library install (site-packages, a venv) is never registered. Returns
|
|
68
|
+
the root registered, or None when refused."""
|
|
69
|
+
try:
|
|
70
|
+
p = Path(path).expanduser().resolve()
|
|
71
|
+
except (OSError, ValueError):
|
|
72
|
+
return None
|
|
73
|
+
root = p if p.is_dir() else project_root_of(p)
|
|
74
|
+
if root is None or _LIBRARY_PARTS & set(root.parts):
|
|
75
|
+
return None
|
|
76
|
+
if root not in _EDITABLE_ROOTS:
|
|
77
|
+
_EDITABLE_ROOTS.append(root)
|
|
78
|
+
_EDITABLE_SOURCE_CACHE.clear()
|
|
79
|
+
return root
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# Files that mark a project's root when walking up from one of its sources.
|
|
83
|
+
_PROJECT_MARKERS = (".git", "pyproject.toml", "setup.py", "setup.cfg")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def project_root_of(file) -> Path | None:
|
|
87
|
+
"""The nearest ancestor of `file` carrying a project marker
|
|
88
|
+
(_PROJECT_MARKERS), else the file's own directory; None when the file
|
|
89
|
+
has no directory."""
|
|
90
|
+
try:
|
|
91
|
+
p = Path(file).resolve()
|
|
92
|
+
except (OSError, ValueError):
|
|
93
|
+
return None
|
|
94
|
+
start = p.parent if not p.is_dir() else p
|
|
95
|
+
for d in (start, *start.parents):
|
|
96
|
+
if any((d / m).exists() for m in _PROJECT_MARKERS):
|
|
97
|
+
return d
|
|
98
|
+
return start
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# str(source_file) -> bool. Whether a path is project source never changes
|
|
102
|
+
# within a session, but resolve() walks every path component with an
|
|
103
|
+
# lstat+readlink - ~30 GIO round-trips - and this gate runs per FRAME per code
|
|
104
|
+
# view (resolve_address). Under a CPU-bound background thread each round-trip
|
|
105
|
+
# waits up to a poll interval, so the uncached gate alone stretched frames by
|
|
106
|
+
# hundreds of ms (all-thread sampling 2026-07-31: ~half the render thread's
|
|
107
|
+
# slow samples were in realpath under this call). Bounded and cleared on hotswap.
|
|
108
|
+
_EDITABLE_SOURCE_CACHE = {}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def is_editable_source(source_file) -> bool:
|
|
112
|
+
"""True only for source inside a registered project tree (editable_roots:
|
|
113
|
+
this checkout and every meltygui app's own project). Library code
|
|
114
|
+
(site-packages / dist-packages / the venv / the stdlib) is read-only to
|
|
115
|
+
the editor, so we never resolve or write to it."""
|
|
116
|
+
key = str(source_file)
|
|
117
|
+
got = _EDITABLE_SOURCE_CACHE.get(key)
|
|
118
|
+
if got is not None:
|
|
119
|
+
return got
|
|
120
|
+
try:
|
|
121
|
+
p = Path(source_file).resolve()
|
|
122
|
+
except (OSError, ValueError):
|
|
123
|
+
return False
|
|
124
|
+
ok = True
|
|
125
|
+
if {"site-packages", "dist-packages"} & set(p.parts):
|
|
126
|
+
ok = False
|
|
127
|
+
else:
|
|
128
|
+
ok = any(p.is_relative_to(root) for root in _EDITABLE_ROOTS)
|
|
129
|
+
if len(_EDITABLE_SOURCE_CACHE) > 4096:
|
|
130
|
+
_EDITABLE_SOURCE_CACHE.clear()
|
|
131
|
+
_EDITABLE_SOURCE_CACHE[key] = ok
|
|
132
|
+
return ok
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
_LIBRARY_PARTS = frozenset({"site-packages", "dist-packages", "venv", ".venv",
|
|
136
|
+
"node_modules"})
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def writable_file_refusal(path) -> str | None:
|
|
140
|
+
"""Why the PLAIN-FILE codecs (TextFileCodec and friends) will not edit
|
|
141
|
+
`path` — a short human reason — or None when they will.
|
|
142
|
+
|
|
143
|
+
The gentler gate: the folder-tree windows mount arbitrary directories and
|
|
144
|
+
apps open whatever they are handed, so whole-file editing is allowed
|
|
145
|
+
anywhere the process may write (a /tmp scratch file, a mounted drive) —
|
|
146
|
+
unlike code codecs, which hotswap live objects and stay pinned to the
|
|
147
|
+
project tree (is_editable_source). Library installs are still refused
|
|
148
|
+
whatever their permissions: a venv is writable too, and writing into
|
|
149
|
+
site-packages through a folder window is the same disaster the strict
|
|
150
|
+
gate exists to prevent. (It used to require $HOME instead of write
|
|
151
|
+
permission, and a refused file showed as a tab that never loaded.)
|
|
152
|
+
A path that does not exist yet is judged by its directory."""
|
|
153
|
+
try:
|
|
154
|
+
p = Path(path).resolve()
|
|
155
|
+
except (OSError, ValueError) as e:
|
|
156
|
+
return f"path cannot be resolved ({e})"
|
|
157
|
+
hit = _LIBRARY_PARTS & set(p.parts)
|
|
158
|
+
if hit:
|
|
159
|
+
return f"inside a library install ({sorted(hit)[0]}/)"
|
|
160
|
+
target = p if p.exists() else p.parent
|
|
161
|
+
if not os.access(target, os.W_OK):
|
|
162
|
+
return "no write permission"
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def is_writable_file(path) -> bool:
|
|
167
|
+
"""The plain-file gate as a bool — see writable_file_refusal."""
|
|
168
|
+
return writable_file_refusal(path) is None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ╔══════════════════════════════════════════════════════════════════════════════╗
|
|
172
|
+
# ║ Address ║
|
|
173
|
+
# ╚══════════════════════════════════════════════════════════════════════════════╝
|
|
174
|
+
ORIGINAL = object() # sentinel for "no original value found"
|
|
175
|
+
APPLY_ALL = object() # sentinel for "apply to all views, not just the one that originated this Pending"
|
|
176
|
+
|
|
177
|
+
@dataclass
|
|
178
|
+
class FileMeta:
|
|
179
|
+
|
|
180
|
+
mtime: float
|
|
181
|
+
size: int
|
|
182
|
+
|
|
183
|
+
def __eq__(self, other):
|
|
184
|
+
if not isinstance(other, FileMeta):
|
|
185
|
+
return NotImplemented
|
|
186
|
+
|
|
187
|
+
return self.mtime == other.mtime and self.size == other.size
|
|
188
|
+
|
|
189
|
+
class Address:
|
|
190
|
+
|
|
191
|
+
# An Address is identified by its LOCATION - (path, start, end) - not its
|
|
192
|
+
# contents. Construction must stay cheap (it runs during resolve_address); hashing
|
|
193
|
+
# the file (a full read + md5 on every construct) was both slow and the wrong
|
|
194
|
+
# identity: two copies of the same span ARE the same address regardless of what
|
|
195
|
+
# the span currently holds. Null defaults so __hash__/__eq__ work on a `self`
|
|
196
|
+
# a live-view stack window captured pre-__init__.
|
|
197
|
+
start = None
|
|
198
|
+
end = None
|
|
199
|
+
|
|
200
|
+
def __init__(self, path, start=None, end=None, source=None, watcher_ds=None):
|
|
201
|
+
self.path = Path(path).resolve()
|
|
202
|
+
self.start = start
|
|
203
|
+
self.end = end
|
|
204
|
+
self.source = source
|
|
205
|
+
self._watcher_ds = watcher_ds
|
|
206
|
+
|
|
207
|
+
def get_meta(self) -> FileMeta:
|
|
208
|
+
s = self.path.stat()
|
|
209
|
+
return FileMeta(mtime=s.st_mtime, size=s.st_size)
|
|
210
|
+
|
|
211
|
+
def __eq__(self, other):
|
|
212
|
+
if not isinstance(other, Address):
|
|
213
|
+
return NotImplemented
|
|
214
|
+
return (self.path == other.path
|
|
215
|
+
and self.start == other.start
|
|
216
|
+
and self.end == other.end)
|
|
217
|
+
|
|
218
|
+
def __hash__(self):
|
|
219
|
+
return hash((self.path, self.start, self.end))
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# ╔══════════════════════════════════════════════════════════════════════════════╗
|
|
223
|
+
# ║ Address resolution ║
|
|
224
|
+
# ╚══════════════════════════════════════════════════════════════════════════════╝
|
|
225
|
+
class ValueDict:
|
|
226
|
+
"""An if/elif/else block's contents, as a dict subclass.
|
|
227
|
+
|
|
228
|
+
isinstance(c, dict) → True, so iteration/access works normally.
|
|
229
|
+
isinstance(c, Conditional) → True, so the UI can render a
|
|
230
|
+
collapsible conditional block.
|
|
231
|
+
|
|
232
|
+
The .condition attribute holds the full condition text
|
|
233
|
+
(e.g. "if selected", "elif pressed", "else").
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
def __init__(self, cached_value, others=None):
|
|
237
|
+
self.others = others or {}
|
|
238
|
+
self.cached_value = cached_value
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
import types
|
|
242
|
+
import weakref
|
|
243
|
+
|
|
244
|
+
_cache: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary()
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def invalidate_address_cache(obj):
|
|
248
|
+
"""Call after hotswapping an object in place."""
|
|
249
|
+
_cache.pop(obj, None)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def update_address_cache(obj, ref: Address):
|
|
253
|
+
"""Store a known-correct Address after a recompile.
|
|
254
|
+
|
|
255
|
+
Avoids re-resolving via inspect.getsourcelines, which can return
|
|
256
|
+
wrong line numbers when linecache holds stale file content after
|
|
257
|
+
a hotswap + file rewrite.
|
|
258
|
+
"""
|
|
259
|
+
try:
|
|
260
|
+
_cache[obj] = ref
|
|
261
|
+
except TypeError:
|
|
262
|
+
pass
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _unwrap(func):
|
|
266
|
+
while hasattr(func, '__wrapped__'):
|
|
267
|
+
func = func.__wrapped__
|
|
268
|
+
return func
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def to_address(value: Any) -> Address:
|
|
272
|
+
"""Convert common types to an Address. Unwraps decorated functions."""
|
|
273
|
+
if isinstance(value, Address):
|
|
274
|
+
return value
|
|
275
|
+
if isinstance(value, ValueDict):
|
|
276
|
+
return to_address(value.cached_value)
|
|
277
|
+
if isinstance(value, Path):
|
|
278
|
+
return Address(value)
|
|
279
|
+
|
|
280
|
+
try:
|
|
281
|
+
cached = _cache[value]
|
|
282
|
+
return cached
|
|
283
|
+
except (KeyError, TypeError):
|
|
284
|
+
# TypeError: value isn't weakly referenceable (shouldn't happen
|
|
285
|
+
# for functions/modules/types, but better safe)
|
|
286
|
+
pass
|
|
287
|
+
|
|
288
|
+
result = _resolve(value)
|
|
289
|
+
|
|
290
|
+
try:
|
|
291
|
+
_cache[value] = result
|
|
292
|
+
except TypeError:
|
|
293
|
+
pass
|
|
294
|
+
|
|
295
|
+
return result
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _evict_linecache(filename: str) -> None:
|
|
299
|
+
"""Force-evict a file from linecache.
|
|
300
|
+
|
|
301
|
+
linecache.checkcache skips entries with mtime=None (loader-managed),
|
|
302
|
+
so after a hotswap + file rewrite, stale content can persist and
|
|
303
|
+
cause inspect.getsourcelines to return wrong line numbers.
|
|
304
|
+
"""
|
|
305
|
+
import linecache
|
|
306
|
+
linecache.cache.pop(filename, None)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def shift_sibling_linenos(saved_source, file_path, after_lineno: int, delta: int,
|
|
310
|
+
include_saved: bool = False) -> None:
|
|
311
|
+
"""Shift co_firstlineno of every code object in the same file
|
|
312
|
+
whose first line is > after_lineno, by `delta` lines.
|
|
313
|
+
|
|
314
|
+
Why: inspect.findsource walks backward from co_firstlineno hunting
|
|
315
|
+
for `def`/`class`/`lambda`/`@`. After we expand a function in the
|
|
316
|
+
file, sibling functions below have stale co_firstlineno that lands
|
|
317
|
+
inside the expanded body — findsource then walks back and returns
|
|
318
|
+
the wrong function's source. Patching co_firstlineno keeps every
|
|
319
|
+
other function's line numbers truthful without touching their code.
|
|
320
|
+
|
|
321
|
+
include_saved=True also shifts the saved source itself. Use this for an
|
|
322
|
+
edit ABOVE the saved span (e.g. an inserted import): the saved function's
|
|
323
|
+
own def moved down too, so its co_firstlineno must move with it — otherwise
|
|
324
|
+
the next resolve walks findsource back past the moved def to line 0.
|
|
325
|
+
"""
|
|
326
|
+
if delta == 0 or saved_source is None:
|
|
327
|
+
return
|
|
328
|
+
|
|
329
|
+
if isinstance(saved_source, types.ModuleType):
|
|
330
|
+
module = saved_source
|
|
331
|
+
else:
|
|
332
|
+
module_name = getattr(saved_source, "__module__", None)
|
|
333
|
+
module = sys.modules.get(module_name) if module_name else None
|
|
334
|
+
if module is None:
|
|
335
|
+
return
|
|
336
|
+
|
|
337
|
+
try:
|
|
338
|
+
target = Path(file_path).resolve()
|
|
339
|
+
except (OSError, ValueError):
|
|
340
|
+
return
|
|
341
|
+
|
|
342
|
+
def _same_file(code) -> bool:
|
|
343
|
+
try:
|
|
344
|
+
return Path(code.co_filename).resolve() == target
|
|
345
|
+
except (OSError, ValueError):
|
|
346
|
+
return code.co_filename == str(target)
|
|
347
|
+
|
|
348
|
+
# Compare/shift the UNWRAPPED function. A decorated function (@core_func,
|
|
349
|
+
# @window, ...) stored in module/class vars is the WRAPPER: its __code__ lives
|
|
350
|
+
# in the decorator's file (core_render.py), so _same_file(wrapper) is False
|
|
351
|
+
# and it would be skipped - leaving the real function's co_firstlineno stale.
|
|
352
|
+
# function_to_address resolves via inspect.getsourcelines(inspect.unwrap(func)),
|
|
353
|
+
# which keys on the unwrapped co_firstlineno; that's the one that must shift so
|
|
354
|
+
# the next resolve doesn't walk findsource back to the wrong def (or line 0).
|
|
355
|
+
saved_inner = (saved_source if isinstance(saved_source, types.ModuleType)
|
|
356
|
+
else inspect.unwrap(saved_source))
|
|
357
|
+
|
|
358
|
+
def _maybe_shift(func):
|
|
359
|
+
# inspect.unwrap can hand back a non-function (a wrapper method /
|
|
360
|
+
# builtin / slot for an overridden dunder) with no internal __code__. It
|
|
361
|
+
# can also raise on a pathological __wrapped__ chain. EITHER WAY this must
|
|
362
|
+
# not abort the walk: an unhandled error here would skip every function
|
|
363
|
+
# defined BELOW the offending member, leaving their co_firstlineno stale -
|
|
364
|
+
# which is exactly how draw_context_menu kept resolving to the wrong def
|
|
365
|
+
# after draw_collection grew above it.
|
|
366
|
+
try:
|
|
367
|
+
inner = inspect.unwrap(func)
|
|
368
|
+
except Exception:
|
|
369
|
+
return
|
|
370
|
+
if inner is saved_inner and not include_saved:
|
|
371
|
+
return
|
|
372
|
+
code = getattr(inner, "__code__", None)
|
|
373
|
+
if not isinstance(code, types.CodeType):
|
|
374
|
+
return
|
|
375
|
+
if not _same_file(code):
|
|
376
|
+
return
|
|
377
|
+
if code.co_firstlineno > after_lineno:
|
|
378
|
+
try:
|
|
379
|
+
inner.__code__ = code.replace(
|
|
380
|
+
co_firstlineno=code.co_firstlineno + delta)
|
|
381
|
+
except (AttributeError, TypeError):
|
|
382
|
+
# Read-only / non-Python code object - can't shift, but don't
|
|
383
|
+
# let it stop the rest of the walk.
|
|
384
|
+
pass
|
|
385
|
+
|
|
386
|
+
def _walk_class(cls):
|
|
387
|
+
if cls is saved_source and not include_saved:
|
|
388
|
+
return
|
|
389
|
+
for val in list(vars(cls).values()):
|
|
390
|
+
try:
|
|
391
|
+
if isinstance(val, types.FunctionType):
|
|
392
|
+
_maybe_shift(val)
|
|
393
|
+
elif isinstance(val, type):
|
|
394
|
+
_walk_class(val)
|
|
395
|
+
elif isinstance(val, (staticmethod, classmethod)):
|
|
396
|
+
inner = getattr(val, "__func__", None)
|
|
397
|
+
if isinstance(inner, types.FunctionType):
|
|
398
|
+
_maybe_shift(inner)
|
|
399
|
+
except Exception:
|
|
400
|
+
# One bad member must never strand the functions after it.
|
|
401
|
+
continue
|
|
402
|
+
|
|
403
|
+
for val in list(vars(module).values()):
|
|
404
|
+
try:
|
|
405
|
+
if isinstance(val, types.FunctionType):
|
|
406
|
+
_maybe_shift(val)
|
|
407
|
+
elif isinstance(val, type):
|
|
408
|
+
_walk_class(val)
|
|
409
|
+
except Exception:
|
|
410
|
+
continue
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _resolve(value: Any) -> Address | None:
|
|
414
|
+
import inspect, types
|
|
415
|
+
if isinstance(value, types.FunctionType):
|
|
416
|
+
unwrapped = inspect.unwrap(value)
|
|
417
|
+
source_file = inspect.getfile(unwrapped)
|
|
418
|
+
_evict_linecache(source_file)
|
|
419
|
+
source_lines, start_lineno = inspect.getsourcelines(unwrapped)
|
|
420
|
+
return Address(Path(source_file), start_lineno - 1,
|
|
421
|
+
start_lineno - 1 + len(source_lines))
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
if isinstance(value, types.ModuleType):
|
|
425
|
+
return Address(Path(value.__file__))
|
|
426
|
+
|
|
427
|
+
if isinstance(value, type):
|
|
428
|
+
if value.__module__ in ('builtins', '_collections_abc'):
|
|
429
|
+
return None
|
|
430
|
+
try:
|
|
431
|
+
import inspect
|
|
432
|
+
source_file = inspect.getfile(value)
|
|
433
|
+
_evict_linecache(source_file)
|
|
434
|
+
source_lines, start_lineno = inspect.getsourcelines(value)
|
|
435
|
+
return Address(Path(source_file), start_lineno - 1,
|
|
436
|
+
start_lineno - 1 + len(source_lines))
|
|
437
|
+
except (TypeError, OSError):
|
|
438
|
+
return None
|
|
439
|
+
|
|
440
|
+
return None
|
|
441
|
+
# ╔══════════════════════════════════════════════════════════════════════════════╗
|
|
442
|
+
# ║ Per-cache_id watch state (fully isolated per view) ║
|
|
443
|
+
# ╚══════════════════════════════════════════════════════════════════════════════╝
|
|
444
|
+
|
|
445
|
+
class _WatchState:
|
|
446
|
+
"""Everything a single view needs for one converter on one file."""
|
|
447
|
+
__slots__ = ("ref", "mtime", "size", "cached_result",
|
|
448
|
+
"original_output_load", "original_input_load",
|
|
449
|
+
"original_output_save", "original_input_save")
|
|
450
|
+
|
|
451
|
+
def __init__(self, ref: Address):
|
|
452
|
+
self.ref = ref
|
|
453
|
+
self.mtime: float = 0.0
|
|
454
|
+
self.size: int = 0
|
|
455
|
+
self.cached_result: Any = None
|
|
456
|
+
self.original_output_load: Any = None
|
|
457
|
+
self.original_input_load: Any = None
|
|
458
|
+
self.original_output_save: Any = None
|
|
459
|
+
self.original_input_save: Any = None
|
|
460
|
+
|
|
461
|
+
def stat_file(self) -> tuple[float, int]:
|
|
462
|
+
s = self.ref.path.stat()
|
|
463
|
+
return s.st_mtime, s.st_size
|
|
464
|
+
|
|
465
|
+
def is_stale(self) -> bool:
|
|
466
|
+
mtime, size = self.stat_file()
|
|
467
|
+
return mtime != self.mtime or size != self.size
|
|
468
|
+
|
|
469
|
+
def mark_current(self):
|
|
470
|
+
self.mtime, self.size = self.stat_file()
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
_all_caches: list[dict] = []
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _new_cache() -> dict[str, _WatchState]:
|
|
477
|
+
cache: dict[str, _WatchState] = {}
|
|
478
|
+
_all_caches.append(cache)
|
|
479
|
+
return cache
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def clear_watch_cache():
|
|
483
|
+
"""Clear all cached state. Useful for tests."""
|
|
484
|
+
for cache in _all_caches:
|
|
485
|
+
cache.clear()
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def get_original_value(cache_id: str | None = None,
|
|
489
|
+
ref: Address | None = None,
|
|
490
|
+
of_type: type | None = None) -> Any:
|
|
491
|
+
"""Find original_value across all caches.
|
|
492
|
+
|
|
493
|
+
Search by cache_id (exact key) or by ref (scan for matching Address).
|
|
494
|
+
of_type filters the result.
|
|
495
|
+
"""
|
|
496
|
+
for cache in _all_caches:
|
|
497
|
+
if cache_id is not None:
|
|
498
|
+
watch = cache.get(cache_id)
|
|
499
|
+
if watch is not None and watch.original_value is not None:
|
|
500
|
+
if of_type is None or isinstance(watch.original_value, of_type):
|
|
501
|
+
return watch.original_value
|
|
502
|
+
if ref is not None:
|
|
503
|
+
for watch in cache.values():
|
|
504
|
+
if watch.ref == ref and watch.original_input_load is not None:
|
|
505
|
+
if of_type is None or isinstance(watch.original_input_load, of_type):
|
|
506
|
+
return watch.original_input_load
|
|
507
|
+
return None
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
# ╔══════════════════════════════════════════════════════════════════════════════╗
|
|
511
|
+
# ║ Helpers ║
|
|
512
|
+
# ╚══════════════════════════════════════════════════════════════════════════════╝
|
|
513
|
+
|
|
514
|
+
def _accepts_apply(fn: Callable) -> bool:
|
|
515
|
+
import inspect as _inspect
|
|
516
|
+
try:
|
|
517
|
+
return "apply" in _inspect.signature(fn).parameters
|
|
518
|
+
except (ValueError, TypeError):
|
|
519
|
+
return False
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _call_fn(fn, args, apply, *, fn_takes_apply):
|
|
523
|
+
if fn_takes_apply:
|
|
524
|
+
return fn(*args, apply=apply)
|
|
525
|
+
return fn(*args)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _update_address_for_id(cache_id: str, ref: Address) -> None:
|
|
529
|
+
"""Update ref on all watches for a cache_id across all caches.
|
|
530
|
+
|
|
531
|
+
Called after a save that may have changed the line range so every
|
|
532
|
+
converter in the chain uses the correct range on its next reload.
|
|
533
|
+
"""
|
|
534
|
+
for cache in _all_caches:
|
|
535
|
+
watch = cache.get(cache_id)
|
|
536
|
+
if watch is not None:
|
|
537
|
+
watch.ref = ref
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
# ╔══════════════════════════════════════════════════════════════════════════════╗
|
|
541
|
+
# ║ Load wrapper ║
|
|
542
|
+
# ╚══════════════════════════════════════════════════════════════════════════════╝
|
|
543
|
+
|
|
544
|
+
def make_load_wrapper(fn: Callable, load_data: Callable,
|
|
545
|
+
from_type: type) -> Callable:
|
|
546
|
+
"""Wrap a forward converter with file watching + caching.
|
|
547
|
+
|
|
548
|
+
cache_id=None → no caching, always load fresh.
|
|
549
|
+
cache_id="x" → cache result per view, detect file changes via mtime/size.
|
|
550
|
+
|
|
551
|
+
First call always loads (no Pending on first visit).
|
|
552
|
+
Subsequent calls return cached result if file unchanged.
|
|
553
|
+
If file changed on disk → Pending(cached_result) until apply=True.
|
|
554
|
+
"""
|
|
555
|
+
local_cache = _new_cache()
|
|
556
|
+
fn_takes_apply = _accepts_apply(fn)
|
|
557
|
+
|
|
558
|
+
@functools.wraps(fn)
|
|
559
|
+
def wrapper(value, apply=False, cache_id=None):
|
|
560
|
+
|
|
561
|
+
# ── No cache_id → stateless, always load ──────────────────
|
|
562
|
+
apply_load = apply and apply.__name__ == load_data.__name__ or apply is APPLY_ALL
|
|
563
|
+
|
|
564
|
+
# if apply:
|
|
565
|
+
# if not apply_load:
|
|
566
|
+
# print(
|
|
567
|
+
# f"Converter {fn.__name__} got apply={apply} but will not apply because it did not originate from {value.__name__}")
|
|
568
|
+
# else:
|
|
569
|
+
# print(f"Applying converter {fn.__name__} with apply={apply}")
|
|
570
|
+
|
|
571
|
+
if cache_id is None:
|
|
572
|
+
ref = to_address(value)
|
|
573
|
+
if not apply_load:
|
|
574
|
+
return Pending(originated=load_data, wrapped=None)
|
|
575
|
+
data = load_data(ref)
|
|
576
|
+
return _call_fn(fn, (data, ref), apply, fn_takes_apply=fn_takes_apply)
|
|
577
|
+
|
|
578
|
+
# ── Get or create this view's watch ─────────────────────────
|
|
579
|
+
|
|
580
|
+
watch = local_cache.get(cache_id)
|
|
581
|
+
if watch is None:
|
|
582
|
+
ref = to_address(value)
|
|
583
|
+
watch = _WatchState(ref)
|
|
584
|
+
local_cache[cache_id] = watch
|
|
585
|
+
|
|
586
|
+
if watch is None and not apply_load:
|
|
587
|
+
return Pending(originated=load_data, wrapped=value)
|
|
588
|
+
|
|
589
|
+
watch.original_input_load = value
|
|
590
|
+
|
|
591
|
+
# ── Check this view's own mtime/size ────────────────────────
|
|
592
|
+
|
|
593
|
+
stale = watch.is_stale() if watch is not None else True
|
|
594
|
+
|
|
595
|
+
if not stale and watch.cached_result is not None:
|
|
596
|
+
return watch.cached_result
|
|
597
|
+
|
|
598
|
+
if not apply_load:
|
|
599
|
+
return Pending(originated=load_data, wrapped=watch.cached_result)
|
|
600
|
+
|
|
601
|
+
# ── Reload (uses watch.ref, not re-inspected) ───────────────
|
|
602
|
+
|
|
603
|
+
data = load_data(watch.ref)
|
|
604
|
+
result = _call_fn(fn, (value, data, watch.ref), apply, fn_takes_apply=fn_takes_apply)
|
|
605
|
+
watch.cached_result = result
|
|
606
|
+
watch.original_output_load = data
|
|
607
|
+
watch.mark_current()
|
|
608
|
+
return result
|
|
609
|
+
|
|
610
|
+
wrapper._watched_from_type = from_type
|
|
611
|
+
wrapper._local_cache = local_cache
|
|
612
|
+
wrapper._accepts_apply = True
|
|
613
|
+
wrapper._accepts_cache_id = True
|
|
614
|
+
return wrapper
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
# ╔══════════════════════════════════════════════════════════════════════════════╗
|
|
618
|
+
# ║ Save wrapper ║
|
|
619
|
+
# ╚══════════════════════════════════════════════════════════════════════════════╝
|
|
620
|
+
|
|
621
|
+
def make_save_wrapper(fn: Callable, save_data: Callable,
|
|
622
|
+
to_type: type,
|
|
623
|
+
load_cache: dict | None = None) -> Callable:
|
|
624
|
+
"""Wrap an inverse converter with dirty detection + save.
|
|
625
|
+
|
|
626
|
+
cache_id=None → stateless pass-through.
|
|
627
|
+
cache_id="x" → dirty detect against original_data from the paired load.
|
|
628
|
+
|
|
629
|
+
Returns watch.original_value (the original input to the forward
|
|
630
|
+
converter, e.g. the function reference) on all paths.
|
|
631
|
+
"""
|
|
632
|
+
fn_takes_apply = _accepts_apply(fn)
|
|
633
|
+
|
|
634
|
+
@functools.wraps(fn)
|
|
635
|
+
def wrapper(converter_input, apply=False, cache_id=None):
|
|
636
|
+
|
|
637
|
+
apply_save = apply and apply.__name__ == save_data.__name__ or apply is APPLY_ALL
|
|
638
|
+
|
|
639
|
+
# if apply:
|
|
640
|
+
# if not apply_save:
|
|
641
|
+
# print(f"Converter {fn.__name__} got apply={apply} but will not apply because it did not originate from {save_data.__name__}")
|
|
642
|
+
# else:
|
|
643
|
+
# print(f"Applying converter {fn.__name__} with apply={apply}")
|
|
644
|
+
|
|
645
|
+
# ── No cache_id → stateless pass-through ────────────────────
|
|
646
|
+
|
|
647
|
+
if cache_id is None:
|
|
648
|
+
return _call_fn(fn, (converter_input,), apply, fn_takes_apply=fn_takes_apply)
|
|
649
|
+
|
|
650
|
+
# ── Find this view's paired load watch ──────────────────────
|
|
651
|
+
watch: _WatchState = load_cache.get(cache_id) if load_cache else None
|
|
652
|
+
if watch is None:
|
|
653
|
+
return converter_input # never loaded, nothing to save
|
|
654
|
+
|
|
655
|
+
# ── Extract data from converter function ─────────────────────
|
|
656
|
+
|
|
657
|
+
converter_output = _call_fn(fn, (converter_input,), apply, fn_takes_apply=fn_takes_apply)
|
|
658
|
+
|
|
659
|
+
# ── Dirty detection ─────────────────────────────────────────
|
|
660
|
+
|
|
661
|
+
is_dirty = (watch.original_output_load is not None
|
|
662
|
+
and converter_output != watch.original_output_load)
|
|
663
|
+
|
|
664
|
+
if not is_dirty:
|
|
665
|
+
|
|
666
|
+
return watch.original_input_load
|
|
667
|
+
|
|
668
|
+
if not apply_save:
|
|
669
|
+
status = "dirty"
|
|
670
|
+
try:
|
|
671
|
+
lines1 = str(watch.original_output_load).splitlines(keepends=True)
|
|
672
|
+
lines2 = str(converter_output).splitlines(keepends=True)
|
|
673
|
+
|
|
674
|
+
# Unified diff (like `git diff`)
|
|
675
|
+
diff = difflib.unified_diff(lines1, lines2, fromfile="original.py", tofile="modified.py")
|
|
676
|
+
# print("".join(diff))
|
|
677
|
+
status = "".join(diff)
|
|
678
|
+
except Exception as e:
|
|
679
|
+
print(f"Diff failed: {e}")
|
|
680
|
+
|
|
681
|
+
return Pending(originated=save_data, wrapped=converter_output, status=status)
|
|
682
|
+
|
|
683
|
+
# ── Save ────────────────────────────────────────────────────
|
|
684
|
+
|
|
685
|
+
ref = watch.ref
|
|
686
|
+
updated_ref = save_data(converter_input, ref, converter_output, watch)
|
|
687
|
+
if isinstance(updated_ref, Pending):
|
|
688
|
+
return updated_ref
|
|
689
|
+
if isinstance(updated_ref, Address):
|
|
690
|
+
ref = updated_ref
|
|
691
|
+
|
|
692
|
+
watch.original_output_load = converter_output
|
|
693
|
+
watch.ref = ref
|
|
694
|
+
watch.mark_current()
|
|
695
|
+
_update_address_for_id(cache_id, ref)
|
|
696
|
+
|
|
697
|
+
return watch.original_input_load
|
|
698
|
+
|
|
699
|
+
wrapper._watched_to_type = to_type
|
|
700
|
+
wrapper._accepts_apply = True
|
|
701
|
+
wrapper._accepts_cache_id = True
|
|
702
|
+
return wrapper
|