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,1255 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import difflib
|
|
3
|
+
import hashlib
|
|
4
|
+
import inspect
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
import textwrap
|
|
9
|
+
import tokenize
|
|
10
|
+
import types
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from enum import EnumType
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from meltygui.code.fileref import Address
|
|
16
|
+
from meltygui.code.fileref import _evict_linecache
|
|
17
|
+
from meltygui.code.fileref import shift_sibling_linenos
|
|
18
|
+
from meltygui.code.fileref import is_editable_source
|
|
19
|
+
from meltygui.code.fileref import is_writable_file
|
|
20
|
+
from meltygui.code.chain_converters import _ensure_import_lines
|
|
21
|
+
from meltygui.code.chain_converters import _resolve_call_address
|
|
22
|
+
from meltygui.code.chain_converters import _split_span_at_call
|
|
23
|
+
from meltygui.code.chain_converters import DiskSpanText
|
|
24
|
+
from meltygui.core.conversion.bubbling import base_of_bubbling
|
|
25
|
+
from meltygui.code.file_converters import _detect_newline
|
|
26
|
+
from meltygui.core.rendering.core_decoration import Core
|
|
27
|
+
|
|
28
|
+
from meltygui.core.melty import Melty
|
|
29
|
+
from meltygui.core.melty import FileWatch
|
|
30
|
+
from meltygui.core.diagnostics.perf_trace import trace_rl as _ptrace_rl
|
|
31
|
+
from meltygui.graphics.texture_manager import PIL_TO_GL_FORMAT
|
|
32
|
+
from meltygui.graphics.texture_manager import PendingTexture
|
|
33
|
+
|
|
34
|
+
# No GL imports here: ImageCodec.load only DECODES (background thread); the GL
|
|
35
|
+
# upload runs on the UI thread via PendingTexture.pending_upload.
|
|
36
|
+
from PIL import Image
|
|
37
|
+
import io
|
|
38
|
+
import mimetypes
|
|
39
|
+
|
|
40
|
+
NO_DATA = object()
|
|
41
|
+
|
|
42
|
+
extension_to_codec = {}
|
|
43
|
+
type_to_codec = {}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class SaveConflict:
|
|
47
|
+
"""Returned by codec.save when the on-disk span no longer matches what was
|
|
48
|
+
last loaded/saved — an external write landed under the (debounced) save.
|
|
49
|
+
Splicing anyway would replace the WRONG lines, so the write is refused;
|
|
50
|
+
code_file_io keeps the edit pending and surfaces the Load / Keep-mine
|
|
51
|
+
conflict UI instead."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, reason):
|
|
54
|
+
self.reason = reason
|
|
55
|
+
|
|
56
|
+
def __repr__(self):
|
|
57
|
+
return f"SaveConflict({self.reason!r})"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _span_fingerprint(lines):
|
|
61
|
+
"""Content hash of a span's lines, line-ending agnostic — the same span
|
|
62
|
+
fingerprints identically whether the lines came from linecache (keep "\\n"),
|
|
63
|
+
a CRLF file split, or a plain split. Used to verify at save time that the
|
|
64
|
+
on-disk span still holds what we last loaded/saved before splicing over it."""
|
|
65
|
+
h = hashlib.sha1()
|
|
66
|
+
for line in lines:
|
|
67
|
+
h.update(line.rstrip("\r\n").encode("utf-8", "surrogatepass"))
|
|
68
|
+
h.update(b"\n")
|
|
69
|
+
return h.hexdigest()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _source_and_newline(address, source_text=None):
|
|
73
|
+
"""The file's full text + dominant newline for a codec load.
|
|
74
|
+
|
|
75
|
+
Normally reads disk. `source_text` (the EXACT text an in-process write just
|
|
76
|
+
produced, handed over by FileWatch.get_self_write_text) is used instead — a
|
|
77
|
+
self-write sync that skips the disk round trip AND the "Loading…" event. The
|
|
78
|
+
span slice + `_span_fingerprint` downstream are identical either way, since
|
|
79
|
+
the recorded text IS what codec.save wrote to the file."""
|
|
80
|
+
if source_text is not None:
|
|
81
|
+
return source_text, _detect_newline(source_text.encode("utf-8", "surrogatepass"))
|
|
82
|
+
data = address.path.read_bytes()
|
|
83
|
+
newline = _detect_newline(data)
|
|
84
|
+
try:
|
|
85
|
+
return data.decode("utf-8"), newline
|
|
86
|
+
except UnicodeDecodeError:
|
|
87
|
+
return data.decode("latin-1"), newline
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _block_is_function(source_lines, name):
|
|
91
|
+
"""Does this getsourcelines block actually contain `def <name>`?
|
|
92
|
+
|
|
93
|
+
inspect.findsource trusts co_firstlineno and walks BACKWARD to the nearest
|
|
94
|
+
def-looking line — after an EXTERNAL edit shifted the file (nothing patches
|
|
95
|
+
live linenos for external writes), that lands on a DIFFERENT function or the
|
|
96
|
+
file head, silently. Loading/saving through that wrong span is the
|
|
97
|
+
file-mangling bug, so verify the block names the function we asked for."""
|
|
98
|
+
if not name.isidentifier(): # <lambda> & friends - can't verify
|
|
99
|
+
return True
|
|
100
|
+
pat = re.compile(rf"^\s*(?:async\s+)?def\s+{re.escape(name)}\b")
|
|
101
|
+
return any(pat.match(line) for line in source_lines)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _reanchor_function(unwrapped, source_file):
|
|
105
|
+
"""Re-find a function whose co_firstlineno went stale (an external edit
|
|
106
|
+
shifted the file) by ast-walking the CURRENT file for its __qualname__.
|
|
107
|
+
|
|
108
|
+
Returns (start0, end0, span_lines) — 0-based [start, end) covering the
|
|
109
|
+
decorators + def — and HEALS the live code object's co_firstlineno (set to
|
|
110
|
+
the first decorator line, the compile convention) so subsequent resolves,
|
|
111
|
+
recompiles, and sibling shifts work from truthful coordinates again.
|
|
112
|
+
Returns None when the file doesn't parse (mid-edit), the function is nested
|
|
113
|
+
(`<locals>` — its def isn't addressable by a body walk), or the qualname
|
|
114
|
+
path isn't found."""
|
|
115
|
+
qual = unwrapped.__qualname__
|
|
116
|
+
if "<locals>" in qual or not unwrapped.__name__.isidentifier():
|
|
117
|
+
return None
|
|
118
|
+
try:
|
|
119
|
+
data = Path(source_file).read_bytes()
|
|
120
|
+
try:
|
|
121
|
+
text = data.decode("utf-8")
|
|
122
|
+
except UnicodeDecodeError:
|
|
123
|
+
text = data.decode("latin-1")
|
|
124
|
+
tree = ast.parse(text)
|
|
125
|
+
except (OSError, SyntaxError, ValueError):
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
node, body = None, tree.body
|
|
129
|
+
parts = qual.split(".")
|
|
130
|
+
for i, part in enumerate(parts):
|
|
131
|
+
last = i == len(parts) - 1
|
|
132
|
+
found = None
|
|
133
|
+
for child in body:
|
|
134
|
+
if last and isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)) \
|
|
135
|
+
and child.name == part:
|
|
136
|
+
found = child
|
|
137
|
+
break
|
|
138
|
+
if not last and isinstance(child, ast.ClassDef) and child.name == part:
|
|
139
|
+
found = child
|
|
140
|
+
break
|
|
141
|
+
if found is None:
|
|
142
|
+
return None
|
|
143
|
+
node, body = found, getattr(found, "body", [])
|
|
144
|
+
|
|
145
|
+
decos = getattr(node, "decorator_list", [])
|
|
146
|
+
first_lineno = min([d.lineno for d in decos] + [node.lineno])
|
|
147
|
+
start0, end0 = first_lineno - 1, node.end_lineno
|
|
148
|
+
span_lines = text.splitlines()[start0:end0]
|
|
149
|
+
try:
|
|
150
|
+
unwrapped.__code__ = unwrapped.__code__.replace(co_firstlineno=first_lineno)
|
|
151
|
+
except (AttributeError, TypeError, ValueError):
|
|
152
|
+
pass # read-only code object - the span is still right for this run
|
|
153
|
+
return start0, end0, span_lines
|
|
154
|
+
|
|
155
|
+
# Library-source guard is in address.py (is_editable_source) so the codec and
|
|
156
|
+
# the older file_converters save paths share one gate. Local alias for brevity.
|
|
157
|
+
_is_editable_source = is_editable_source
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass(frozen=True)
|
|
161
|
+
class CallSite:
|
|
162
|
+
"""The dispatch key for `CallerCodec`: where a function is CALLED, not where
|
|
163
|
+
it's defined. A function object resolves to its `def` span; a `CallSite`
|
|
164
|
+
resolves to the call STATEMENT at `(filename, lineno)`.
|
|
165
|
+
|
|
166
|
+
A plain `(filename, lineno)` tuple can't be type-dispatched (it's just a
|
|
167
|
+
tuple), so we wrap it in a dedicated type and register the codec `for_type`.
|
|
168
|
+
This is the pattern future targeted codecs follow too — e.g. a `ClassAttribute`
|
|
169
|
+
type pointing a codec at a single `name = value` line in a class body.
|
|
170
|
+
|
|
171
|
+
Construct from `caller_site(get_live_frames())` (which already strips the
|
|
172
|
+
render-dispatch frames), then hand the `CallSite` to `code_file_io`."""
|
|
173
|
+
filename: str
|
|
174
|
+
lineno: int
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@dataclass(frozen=True)
|
|
178
|
+
class Decorations:
|
|
179
|
+
"""The dispatch key for `DecorationsCodec`: the DECORATOR block above a class
|
|
180
|
+
or function, edited on its own.
|
|
181
|
+
|
|
182
|
+
`Decorations(Toggles)` resolves to just the `@window(...)` line(s) above
|
|
183
|
+
`class Toggles:` — not the class body (that's `TypeCodec`) and not a call site
|
|
184
|
+
(that's `CallSite`). Same family pattern as `CallSite`: wrap the target so the
|
|
185
|
+
codec can be type-dispatched, then hand it to `code_file_io`.
|
|
186
|
+
|
|
187
|
+
`target` is the live class or function object whose decorators are edited. The
|
|
188
|
+
codec slices out the decorator lines for editing and re-runs the decorators by
|
|
189
|
+
recompiling the WHOLE object (see `_recompile_decorations`)."""
|
|
190
|
+
target: object
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _resync_module_linenos(address, old_lines, new_lines):
|
|
194
|
+
"""After a WHOLE-FILE save, shift live co_firstlineno's to match the new text.
|
|
195
|
+
|
|
196
|
+
A SPANNED save knows its single (after_lineno, delta) and calls
|
|
197
|
+
shift_sibling_linenos directly; a whole-file edit can change line counts
|
|
198
|
+
anywhere, so walk the old→new diff and apply one shift per line-count
|
|
199
|
+
change, bottom-up so earlier shifts don't disturb later regions. Without
|
|
200
|
+
this, views rendering LIVE objects from this file (FunctionCodec /
|
|
201
|
+
TypeCodec) resolve stale spans after the save and snap to the nearest def."""
|
|
202
|
+
resync_file_linenos(address.path, old_lines, new_lines)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def resync_file_linenos(path, old_lines, new_lines):
|
|
206
|
+
"""Shift live co_firstlineno's in every module loaded from `path` (resolved
|
|
207
|
+
Path) so they match `new_lines`, given they currently match `old_lines`.
|
|
208
|
+
|
|
209
|
+
The address-free body of _resync_module_linenos: also used by
|
|
210
|
+
PendingSave.resolve_external, which moves live coordinates from the
|
|
211
|
+
last-synced text to the current (externally written) disk WITHOUT any
|
|
212
|
+
disk write of its own.
|
|
213
|
+
|
|
214
|
+
The same file can be materialized under several module names (src.lsd.…
|
|
215
|
+
and lsd.… import roots both exist here), each with its OWN function
|
|
216
|
+
objects — shift every matching module, deduped by identity."""
|
|
217
|
+
opcodes = [(i2, (j2 - j1) - (i2 - i1))
|
|
218
|
+
for tag, i1, i2, j1, j2 in
|
|
219
|
+
difflib.SequenceMatcher(None, old_lines, new_lines, autojunk=False).get_opcodes()
|
|
220
|
+
if tag != "equal" and (j2 - j1) != (i2 - i1)]
|
|
221
|
+
if not opcodes:
|
|
222
|
+
return
|
|
223
|
+
seen, modules = set(), []
|
|
224
|
+
for m in list(sys.modules.values()):
|
|
225
|
+
f = getattr(m, "__file__", None)
|
|
226
|
+
try:
|
|
227
|
+
if f and id(m) not in seen and Path(f).resolve() == path:
|
|
228
|
+
seen.add(id(m))
|
|
229
|
+
modules.append(m)
|
|
230
|
+
except (OSError, ValueError):
|
|
231
|
+
continue
|
|
232
|
+
for module in modules:
|
|
233
|
+
for after_lineno, delta in sorted(opcodes, reverse=True):
|
|
234
|
+
shift_sibling_linenos(module, path,
|
|
235
|
+
after_lineno=after_lineno, delta=delta,
|
|
236
|
+
include_saved=True)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def register_codec(cls=None, **kwargs):
|
|
240
|
+
def wrap(cls):
|
|
241
|
+
if "ext" in kwargs:
|
|
242
|
+
exts = kwargs["ext"]
|
|
243
|
+
if isinstance(exts, str):
|
|
244
|
+
exts = (exts,) # a bare "png" once iterates CHAR BY CHAR
|
|
245
|
+
for ext in exts:
|
|
246
|
+
# Normalize to Path.suffix's shape (".png", lowercase) so
|
|
247
|
+
# registration matches lookup regardless of how it was written.
|
|
248
|
+
if not ext.startswith("."):
|
|
249
|
+
ext = "." + ext
|
|
250
|
+
extension_to_codec[ext.lower()] = cls
|
|
251
|
+
|
|
252
|
+
if "for_type" in kwargs:
|
|
253
|
+
if isinstance(kwargs["for_type"], tuple):
|
|
254
|
+
for t in kwargs["for_type"]:
|
|
255
|
+
type_to_codec[t] = cls
|
|
256
|
+
else:
|
|
257
|
+
type_to_codec[kwargs["for_type"]] = cls
|
|
258
|
+
return cls
|
|
259
|
+
|
|
260
|
+
if cls is None:
|
|
261
|
+
return wrap
|
|
262
|
+
|
|
263
|
+
return wrap(cls)
|
|
264
|
+
|
|
265
|
+
class Codec:
|
|
266
|
+
name = "Base Codec"
|
|
267
|
+
# Base render kwargs for views of this codec's data - the codec IS the
|
|
268
|
+
# data source, so source-level appearance lives here. core_render merges
|
|
269
|
+
# these in as the LOWEST priority layer (args = codec_kwargs | kwargs;
|
|
270
|
+
# everything overrides here) - EXCEPT `tint`, which never propagates:
|
|
271
|
+
# it is the source COLOR-CODE, consumed explicitly by provenance views
|
|
272
|
+
# (the context menu's draw_param_matrix), not an optional wash.
|
|
273
|
+
render_kwargs = {}
|
|
274
|
+
# Optional view override for this codec's loaded data. None (the default)
|
|
275
|
+
# means "use the type": a str lands in the text view the caller wired,
|
|
276
|
+
# anything else goes through draw_any to the type's default renderer
|
|
277
|
+
# (is_default_for) - so a codec whose load() returns a type that already
|
|
278
|
+
# has a view needs nothing here. Set it only for a type without a default
|
|
279
|
+
# view or to pin a non-default one. See code_file_io's _set_view.
|
|
280
|
+
view_func = None
|
|
281
|
+
# Whether edits to the loaded value round-trip to the file. False (images,
|
|
282
|
+
# mainly - anything save() refuses) makes code_file_io ignore view edits
|
|
283
|
+
# (no dirty / save runner) and reload external changes immediately instead
|
|
284
|
+
# of parking on the merge/conflict banner: nothing local can be saved.
|
|
285
|
+
editable = True
|
|
286
|
+
# Tab glyph (unicode) for files of this type - the editor's tab bar
|
|
287
|
+
# falls back to it if the file's FileMeta entry carries no icon.
|
|
288
|
+
icon = None
|
|
289
|
+
|
|
290
|
+
@staticmethod
|
|
291
|
+
def show_code_buttons(address):
|
|
292
|
+
"""The codec's "this is Python source" switch for code_file_io: the
|
|
293
|
+
Run (hotswap) and Index (jedi) buttons, their hotkeys, AND the error
|
|
294
|
+
checking (the code-host parse + syntax/lint/runtime highlights) all
|
|
295
|
+
ride it. Only meaningful where the loaded text is Python, so the base
|
|
296
|
+
says no; TypeCodec (live Python objects) says yes; TextFileCodec says
|
|
297
|
+
yes for .py paths only."""
|
|
298
|
+
return False
|
|
299
|
+
|
|
300
|
+
@classmethod
|
|
301
|
+
def claims(cls, path):
|
|
302
|
+
"""Extension routing's content veto: codec_for_path asks the
|
|
303
|
+
extension-matched codec whether the file's BYTES actually decode as
|
|
304
|
+
what the extension promises. Declining (False) drops the file through
|
|
305
|
+
to the content sniff instead — a zero-byte or corrupt "image.png"
|
|
306
|
+
becomes editable text / a binary summary rather than a load() that
|
|
307
|
+
throws on every watch-triggered reload. The base accepts everything;
|
|
308
|
+
only codecs whose load() can reject content (ImageCodec) override."""
|
|
309
|
+
return True
|
|
310
|
+
|
|
311
|
+
@staticmethod
|
|
312
|
+
def resolve_address(input_value, draw_state=None, **kwargs):
|
|
313
|
+
return NO_DATA
|
|
314
|
+
|
|
315
|
+
@staticmethod
|
|
316
|
+
def load(path, **kwargs):
|
|
317
|
+
return NO_DATA
|
|
318
|
+
|
|
319
|
+
@staticmethod
|
|
320
|
+
def save(data, file_path, **kwargs):
|
|
321
|
+
return False
|
|
322
|
+
|
|
323
|
+
# ── Per-file attributes (AppModel.file_meta_collection) ────────────────
|
|
324
|
+
# The codec is an input source (the _ViewSource row / SourcePriority.
|
|
325
|
+
# CODEC). Its class-level render_kwargs are in-memory and codec-wide;
|
|
326
|
+
# per-FILE attributes instead land in FileMetaCollection.file_meta - the
|
|
327
|
+
# DictConversion mirroring the file tree - keyed by the file this
|
|
328
|
+
# element's Address resolves to. Living on AppModel makes persistence
|
|
329
|
+
# automatic (rides the root save), and the folder tree re-applies thes
|
|
330
|
+
# as meta on every run (folder_files._apply_meta).
|
|
331
|
+
|
|
332
|
+
@classmethod
|
|
333
|
+
def file_meta_key(cls, draw_state):
|
|
334
|
+
"""The file-meta dict key (path string) for the file this element
|
|
335
|
+
belongs to, memoized on the draw_state's `_file_meta` field. O(1) BY
|
|
336
|
+
DESIGN — this runs in the wrapper's per-render codec layer, so no
|
|
337
|
+
ancestor walk: the ds's OWN codec-stamped Address resolves it, else
|
|
338
|
+
the key propagates one hop from the parent's memo (parents render
|
|
339
|
+
before children, so a subtree under a resolved view fills in top-down
|
|
340
|
+
across frames; an unbounded walk here froze startup — cycles aside,
|
|
341
|
+
it was O(depth) per view per frame across every codec subtree)."""
|
|
342
|
+
key = getattr(draw_state, "_file_meta", None)
|
|
343
|
+
if isinstance(key, str):
|
|
344
|
+
return key
|
|
345
|
+
path = getattr(getattr(draw_state, "_address", None), "path", None)
|
|
346
|
+
if path:
|
|
347
|
+
key = str(path)
|
|
348
|
+
else:
|
|
349
|
+
parent = getattr(draw_state, "_parent", None)
|
|
350
|
+
pkey = getattr(parent, "_file_meta", None) if parent is not None else None
|
|
351
|
+
key = pkey if isinstance(pkey, str) else None
|
|
352
|
+
if key is not None:
|
|
353
|
+
draw_state._file_meta = key
|
|
354
|
+
return key
|
|
355
|
+
|
|
356
|
+
@classmethod
|
|
357
|
+
def file_meta_entry(cls, draw_state, create=False):
|
|
358
|
+
"""This file's params dict in the shared file-meta store
|
|
359
|
+
(file_meta_store(), what AppModel.file_meta_collection.file_meta is
|
|
360
|
+
too), or None (no file resolves / no entry and not create).
|
|
361
|
+
create=True materializes the entry."""
|
|
362
|
+
from meltygui.models.file_meta import FileMeta
|
|
363
|
+
from meltygui.models.file_meta import file_meta_store
|
|
364
|
+
meta = file_meta_store()
|
|
365
|
+
path = cls.file_meta_key(draw_state)
|
|
366
|
+
if path is None:
|
|
367
|
+
return None
|
|
368
|
+
entry = meta.get(path)
|
|
369
|
+
if not isinstance(entry, dict):
|
|
370
|
+
if not create:
|
|
371
|
+
return None
|
|
372
|
+
entry = meta[path] = FileMeta()
|
|
373
|
+
return entry
|
|
374
|
+
|
|
375
|
+
@classmethod
|
|
376
|
+
def update_file_meta(cls, draw_state, key, value):
|
|
377
|
+
"""Stamp a per-file attribute into the correct file's metadata entry.
|
|
378
|
+
Returns True when the write landed (a file resolved), False when it
|
|
379
|
+
couldn't — the caller falls back to codec-wide behavior."""
|
|
380
|
+
entry = cls.file_meta_entry(draw_state, create=True)
|
|
381
|
+
if entry is None:
|
|
382
|
+
return False
|
|
383
|
+
entry[key] = value
|
|
384
|
+
return True
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
@register_codec(for_type=(type, EnumType))
|
|
388
|
+
class TypeCodec(Codec):
|
|
389
|
+
# Class source - where @defaults lives: dark grey/blue.
|
|
390
|
+
render_kwargs = {"tint": (0.10, 0.12, 0.22, 0.40)}
|
|
391
|
+
|
|
392
|
+
@staticmethod
|
|
393
|
+
def show_code_buttons(address):
|
|
394
|
+
return True # the data IS live Python source - Run/Index apply
|
|
395
|
+
|
|
396
|
+
@staticmethod
|
|
397
|
+
def resolve_address(input_value, draw_state=None, **kwargs):
|
|
398
|
+
if isinstance(input_value, type) and input_value.__module__ not in ('builtins', '_collections_abc'):
|
|
399
|
+
# A runtime-generated bubbling subclass has no source - resolve its base.
|
|
400
|
+
unwrapped = base_of_bubbling(input_value)
|
|
401
|
+
try:
|
|
402
|
+
source_file = inspect.getfile(unwrapped)
|
|
403
|
+
except TypeError:
|
|
404
|
+
return None
|
|
405
|
+
|
|
406
|
+
# Refuse library source - we only ever edit this project's own code.
|
|
407
|
+
if not _is_editable_source(source_file):
|
|
408
|
+
return None
|
|
409
|
+
|
|
410
|
+
if draw_state is not None:
|
|
411
|
+
FileWatch.register_draw_state(draw_state, Path(source_file))
|
|
412
|
+
else:
|
|
413
|
+
return None
|
|
414
|
+
|
|
415
|
+
try:
|
|
416
|
+
mtime = Path(source_file).stat().st_mtime
|
|
417
|
+
except OSError:
|
|
418
|
+
mtime = None
|
|
419
|
+
cached = getattr(draw_state, '_addr_cache', None)
|
|
420
|
+
if cached is not None and cached[0] is input_value and cached[1] == mtime:
|
|
421
|
+
return cached[2]
|
|
422
|
+
|
|
423
|
+
# Timeline: WHY the cached address lookup stale - this miss path pays a
|
|
424
|
+
# whole Python getsourcelines call on the render thread, so a miss
|
|
425
|
+
# per keystroke (e.g. the trailing disk save moving mtime) is a big hitch.
|
|
426
|
+
_why = ("cold" if cached is None
|
|
427
|
+
else "identity" if cached[0] is not input_value
|
|
428
|
+
else f"mtime {cached[1]}->{mtime}")
|
|
429
|
+
_ptrace_rl(("addr-resolve", id(draw_state)), f"addr re-resolve ({_why})",
|
|
430
|
+
target=getattr(input_value, '__name__', '?'))
|
|
431
|
+
_evict_linecache(source_file)
|
|
432
|
+
try:
|
|
433
|
+
source_lines, start_lineno = inspect.getsourcelines(unwrapped)
|
|
434
|
+
except (OSError, TypeError, tokenize.TokenError, SyntaxError) as e:
|
|
435
|
+
if hasattr(draw_state, '_addr_cache') and draw_state._addr_cache is not None:
|
|
436
|
+
return draw_state._addr_cache[2]
|
|
437
|
+
|
|
438
|
+
print(f"[editable_source] could not resolve {getattr(input_value, '__name__', input_value)}: {e}")
|
|
439
|
+
return None
|
|
440
|
+
|
|
441
|
+
address = Address(Path(source_file), start_lineno - 1,
|
|
442
|
+
start_lineno - 1 + len(source_lines),
|
|
443
|
+
source=unwrapped, watcher_ds=draw_state)
|
|
444
|
+
address._span_fp = _span_fingerprint(source_lines)
|
|
445
|
+
draw_state._addr_cache = (input_value, mtime, address)
|
|
446
|
+
return address
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
@classmethod
|
|
450
|
+
def load(cls, address, source_text=None, **kwargs):
|
|
451
|
+
# In-memory overlay: a queued-but-unflushed edit for this span (saves
|
|
452
|
+
# defer to shutdown) is the freshest text; return it as the now-stale
|
|
453
|
+
# disk content, and skip the disk read below. An explicit source_text
|
|
454
|
+
# (a verified reload copy of exact disk content) still takes the slice
|
|
455
|
+
# path below. See PendingSave.pending_text_for.
|
|
456
|
+
if source_text is None:
|
|
457
|
+
from meltygui.editor.pending_save import PendingSave
|
|
458
|
+
pending = PendingSave.pending_text_for(address)
|
|
459
|
+
if pending is not None:
|
|
460
|
+
# Re-baseline the save conflict guard against the app's OWN writes.
|
|
461
|
+
# _span_fp (set at first load) means "the disk content this pending
|
|
462
|
+
# edit was derived from". When ANOTHER surface edits the same span
|
|
463
|
+
# it writes via codec.save, and this view's pending text is
|
|
464
|
+
# rebased onto the new disk by the post-write reload - but _span_fp
|
|
465
|
+
# stayed frozen, so save reads the app's own sibling write as an
|
|
466
|
+
# EXTERNAL conflict and refuses it (the multi-surface false
|
|
467
|
+
# conflict). When the new disk is an in-process write
|
|
468
|
+
# (is_self_write), it is exactly what the rebased pending sits on,
|
|
469
|
+
# so sync _span_fp to match. A genuine EXTERNAL write leaves
|
|
470
|
+
# is_self_write False, the baseline stays stale, and the guard
|
|
471
|
+
# still fires - a real conflict.
|
|
472
|
+
if FileWatch.is_self_write(address.path):
|
|
473
|
+
try:
|
|
474
|
+
text, newline = _source_and_newline(address)
|
|
475
|
+
lines = text.split(newline)
|
|
476
|
+
span = (lines if address.start is None
|
|
477
|
+
else lines[address.start:address.end])
|
|
478
|
+
address._span_fp = _span_fingerprint(span)
|
|
479
|
+
except OSError:
|
|
480
|
+
pass
|
|
481
|
+
return pending
|
|
482
|
+
# Launch-baseline overlay (whole-file loads): PENDING is the studio's
|
|
483
|
+
# copy. A file that drifted on disk since the studio last knew it
|
|
484
|
+
# (ExternalChanges.original - seeded from core_project.py'
|
|
485
|
+
# launch preload via the first-time code_cache pop) opens as that
|
|
486
|
+
# LAST-KNOWN text, not the new disk content: external changes are
|
|
487
|
+
# never auto-pulled into pending; they stay visible through the git
|
|
488
|
+
# file_system proxy / compare until manually merged. The baseline
|
|
489
|
+
# fingerprint arms the save conflict guard, so saving this buffer
|
|
490
|
+
# refuses against the newer disk and routes through the merge
|
|
491
|
+
# surface. Span loads skip it - the baseline's line numbering can't
|
|
492
|
+
# be trusted for a post-drift span address; self-writes skip it
|
|
493
|
+
# - the disk already IS the studio's own text.
|
|
494
|
+
if source_text is None and address.start is None \
|
|
495
|
+
and not FileWatch.is_self_write(address.path):
|
|
496
|
+
from meltygui.editor.external_changes import ExternalChanges
|
|
497
|
+
try:
|
|
498
|
+
_res = str(address.path.resolve())
|
|
499
|
+
except OSError:
|
|
500
|
+
_res = None
|
|
501
|
+
base = ExternalChanges.originals.get(_res) if _res else None
|
|
502
|
+
if isinstance(base, str) and base:
|
|
503
|
+
address._span_fp = _span_fingerprint(base.split("\n"))
|
|
504
|
+
return base
|
|
505
|
+
text, newline = _source_and_newline(address, source_text)
|
|
506
|
+
lines = text.split(newline)
|
|
507
|
+
span_lines = lines[address.start:address.end]
|
|
508
|
+
# Remember what the span held when it was loaded; save verifies the disk
|
|
509
|
+
# still holds this before splicing over it (see save's conflict guard).
|
|
510
|
+
address._span_fp = _span_fingerprint(span_lines)
|
|
511
|
+
out = newline.join(span_lines)
|
|
512
|
+
# Plain disk read (no pending overlay reached this path): stamp the
|
|
513
|
+
# text with the mtime it reflects so the CST's cache can serve /
|
|
514
|
+
# store its parse with no content comparison (see DiskSpanText). Any
|
|
515
|
+
# edit decays it to plain str. An explicit source_text is a VERIFIED
|
|
516
|
+
# self-write of exact disk content, so it carries provenance too.
|
|
517
|
+
if source_text is None or FileWatch.is_self_write(address.path):
|
|
518
|
+
try:
|
|
519
|
+
stamped = DiskSpanText(out)
|
|
520
|
+
stamped._disk_mtime = address.path.stat().st_mtime
|
|
521
|
+
stamped._disk_span = (os.path.realpath(str(address.path)),
|
|
522
|
+
address.start, address.end)
|
|
523
|
+
# Value-carried provenance: the loaded text knows its codec,
|
|
524
|
+
# so rendering it OUTSIDE this codec's own subtree (the
|
|
525
|
+
# the an editor drawing host["value"] with draw_text)
|
|
526
|
+
# still re-establishes the codec context - and with it the
|
|
527
|
+
# per-file kwargs layer. See core_render's codec_scope.
|
|
528
|
+
stamped._codec = cls
|
|
529
|
+
return stamped
|
|
530
|
+
except OSError:
|
|
531
|
+
pass
|
|
532
|
+
return out
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
@staticmethod
|
|
536
|
+
def save(address, data, ensure_import=None, force=False, **kwargs):
|
|
537
|
+
"""Write code_str back into the file at the Address's span — the synchronous
|
|
538
|
+
body of the old @render_func(background=True) _do_save, minus the Pending.
|
|
539
|
+
|
|
540
|
+
ensure_import=(module, name) inserts a missing import in the SAME write so a
|
|
541
|
+
synthesized decorator (e.g. @defaults) resolves. Updates the Address span in
|
|
542
|
+
place and shifts siblings so this frame's Address stays valid; siblings heal
|
|
543
|
+
on the next mtime-driven re-resolve.
|
|
544
|
+
|
|
545
|
+
Refuses the write (returns SaveConflict) when the on-disk span no longer
|
|
546
|
+
fingerprints to what was last loaded/saved there — an external program
|
|
547
|
+
wrote the file under us, and splicing would land on the wrong lines.
|
|
548
|
+
force=True (the user's explicit "Keep mine") skips that guard."""
|
|
549
|
+
# Defense in depth: never write to library source even if an Address
|
|
550
|
+
# somehow points outside the project (resolve_address should already have
|
|
551
|
+
# refused it). A bad span splice bug once corrupted libcst's own source.
|
|
552
|
+
# `_allow_write` is the whole-file opt-out: TextFileCodec stamps it on
|
|
553
|
+
# Addresses it resolved through the gentler is_writable_source gate
|
|
554
|
+
# (folder windows mount paths outside the project), so whole-file saves
|
|
555
|
+
# there pass while code codecs stay pinned to the project tree.
|
|
556
|
+
if not (_is_editable_source(address.path)
|
|
557
|
+
or getattr(address, "_allow_write", False)):
|
|
558
|
+
print(f"[codec.save] refusing to write library source: {address.path}")
|
|
559
|
+
return False
|
|
560
|
+
full = address.path.read_bytes()
|
|
561
|
+
newline = _detect_newline(full)
|
|
562
|
+
try:
|
|
563
|
+
text = full.decode("utf-8")
|
|
564
|
+
except UnicodeDecodeError:
|
|
565
|
+
text = full.decode("latin-1")
|
|
566
|
+
lines = text.split(newline)
|
|
567
|
+
# `data` must use the SAME span convention as load(): a span of N lines is
|
|
568
|
+
# N elements joined by N-1 newlines, with no trailing newline. If the
|
|
569
|
+
# editor (or a cst round-trip) hands us a trailing newline, splitting it
|
|
570
|
+
# yields a phantom empty element - which both splices a spurious blank line
|
|
571
|
+
# into the file AND inflates the line-count delta, so siblings below
|
|
572
|
+
# over-shift by one. That drift accumulates per save until findsource's
|
|
573
|
+
# backward walk lands on the wrong def and the view loses its reference.
|
|
574
|
+
# Stick to the load convention: strip exactly one trailing newline.
|
|
575
|
+
if data.endswith(newline):
|
|
576
|
+
data = data[:-len(newline)]
|
|
577
|
+
new_lines = data.split(newline)
|
|
578
|
+
|
|
579
|
+
old_start, old_end = address.start, address.end
|
|
580
|
+
|
|
581
|
+
# ─── Verify before splice ──────────────────────────────────────────────
|
|
582
|
+
# The span coordinates were resolved on the render thread, possibly
|
|
583
|
+
# hundreds of ms before this (debounced, background) save. If anything
|
|
584
|
+
# else wrote the file in between, our coordinates index DIFFERENT
|
|
585
|
+
# content and splicing would mangle the result (duplicate the function,
|
|
586
|
+
# tear lines). Check the span still holds what we last loaded/saved;
|
|
587
|
+
# on mismatch abort, and let the conflict UI sort it out.
|
|
588
|
+
expected_fp = getattr(address, "_span_fp", None)
|
|
589
|
+
if not force and expected_fp is not None:
|
|
590
|
+
on_disk = lines if old_start is None else lines[old_start:old_end]
|
|
591
|
+
if _span_fingerprint(on_disk) != expected_fp:
|
|
592
|
+
print(f"[codec.save] refused: {address.path.name}"
|
|
593
|
+
f"[{old_start}:{old_end}] changed on disk since load")
|
|
594
|
+
return SaveConflict(f"{address.path.name} changed on disk")
|
|
595
|
+
|
|
596
|
+
if old_start is None: # whole-file (module) address
|
|
597
|
+
old_lines = lines # pre-splice contents, for the lineno resync below
|
|
598
|
+
lines = new_lines
|
|
599
|
+
else:
|
|
600
|
+
lines[old_start:old_end] = new_lines
|
|
601
|
+
|
|
602
|
+
inserted = 0
|
|
603
|
+
insert_idx = None
|
|
604
|
+
if ensure_import is not None:
|
|
605
|
+
# Three shapes: (module, name) - the legacy @defaults;; a full
|
|
606
|
+
# statement string ("import numpy as np" - the editor's
|
|
607
|
+
# missing-import quick-fix); or a list of either (several fixes
|
|
608
|
+
# queued on one entry before the flush).
|
|
609
|
+
_eis = (ensure_import if isinstance(ensure_import, list)
|
|
610
|
+
else [ensure_import])
|
|
611
|
+
for _ei in _eis:
|
|
612
|
+
if isinstance(_ei, str):
|
|
613
|
+
lines, _ins, _idx = _ensure_import_lines(lines, _ei)
|
|
614
|
+
else:
|
|
615
|
+
lines, _ins, _idx = _ensure_import_lines(lines, _ei[0], _ei[1])
|
|
616
|
+
inserted += _ins
|
|
617
|
+
if _idx is not None:
|
|
618
|
+
insert_idx = _idx if insert_idx is None else min(insert_idx, _idx)
|
|
619
|
+
|
|
620
|
+
final_text = newline.join(lines)
|
|
621
|
+
|
|
622
|
+
# Patch live co_firstlineno's BEFORE the write, not after. Other views of
|
|
623
|
+
# this file reload on the mtime bump (FileWatch dispatch / auto_load_edits
|
|
624
|
+
# on the render thread) and re-resolve our span - resolve_address caches
|
|
625
|
+
# per (input, mtime), so a resolve that races a post-write shift sees the
|
|
626
|
+
# NEW file with the OLD linenos, walks findsource back to the wrong def,
|
|
627
|
+
# and pins that wrong span for the new mtime (nothing busts it until the
|
|
628
|
+
# NEXT save). Pre-write updates are safe in the other direction: until the
|
|
629
|
+
# write below bumps mtime, the resolve is pulled from the cache, so the
|
|
630
|
+
# transient "old file + new linenos" state is never observed.
|
|
631
|
+
if old_start is not None:
|
|
632
|
+
resolved_old_end = old_end if old_end is not None else old_start + len(new_lines)
|
|
633
|
+
new_end = old_start + len(new_lines)
|
|
634
|
+
delta = new_end - resolved_old_end
|
|
635
|
+
address.end = new_end
|
|
636
|
+
if inserted: # import landed above our span
|
|
637
|
+
address.start = old_start + inserted
|
|
638
|
+
address.end = new_end + inserted
|
|
639
|
+
# The shift anchor is normally the span's live source; an entry
|
|
640
|
+
# whose source is deliberately NON-code (like auto-import insertion
|
|
641
|
+
# - a plain marker string so recompile_all never hotswaps it) can
|
|
642
|
+
# carry the module to shift as `_shift_source` instead.
|
|
643
|
+
_shift_src = getattr(address, "_shift_source", None) or address.source
|
|
644
|
+
# Shift siblings below for the body span change (original coords)...
|
|
645
|
+
shift_sibling_linenos(_shift_src, address.path,
|
|
646
|
+
after_lineno=resolved_old_end, delta=delta)
|
|
647
|
+
# ...then the import insert moved our def AND everything below it down,
|
|
648
|
+
# so shift the saved source too (include_saved) - else its own
|
|
649
|
+
# co_firstlineno goes stale and the next resolve walks back to line 0.
|
|
650
|
+
if inserted and insert_idx is not None:
|
|
651
|
+
shift_sibling_linenos(_shift_src, address.path,
|
|
652
|
+
after_lineno=insert_idx, delta=inserted,
|
|
653
|
+
include_saved=True)
|
|
654
|
+
else:
|
|
655
|
+
# Whole-file address: the splice gave us a single delta, so resync
|
|
656
|
+
# live line numbers from the old→new diff (see the helper).
|
|
657
|
+
_resync_module_linenos(address, old_lines, lines)
|
|
658
|
+
|
|
659
|
+
# Stamp the watch hash BEFORE writing so our own write doesn't read back as a
|
|
660
|
+
# stale external change.
|
|
661
|
+
FileWatch.set_hash_from_content(address.path, final_text, draw_state=address._watcher_ds)
|
|
662
|
+
address.path.write_text(final_text, encoding="utf-8")
|
|
663
|
+
# The on-disk span is now exactly what we wrote - refresh the conflict
|
|
664
|
+
# guard's baseline so the next save verifies against THIS write.
|
|
665
|
+
address._span_fp = _span_fingerprint(new_lines if old_start is not None else lines)
|
|
666
|
+
|
|
667
|
+
# Serve any post-write resolve from cache. This save kept address.start/end
|
|
668
|
+
# truthful, while a getsourcelines re-resolve of the file we JUST wrote
|
|
669
|
+
# can silently TRUNCATE: a broken (column-0) line in the saved buffer ends
|
|
670
|
+
# inspect's block scan at the dedent with no exception, so the raise-guard
|
|
671
|
+
# in resolve_address never fires - and the next save would splice the full
|
|
672
|
+
# buffer into the short span, truncating the tail. Bumping the cached mtime
|
|
673
|
+
# to the written value keeps the save-maintained address authoritative; a
|
|
674
|
+
# GENUINE external write bumps mtime again and still re-resolves.
|
|
675
|
+
ds = address._watcher_ds
|
|
676
|
+
cached = getattr(ds, "_addr_cache", None) if ds is not None else None
|
|
677
|
+
if cached is not None and cached[2] is address:
|
|
678
|
+
try:
|
|
679
|
+
ds._addr_cache = (cached[0], address.path.stat().st_mtime, address)
|
|
680
|
+
except OSError:
|
|
681
|
+
pass
|
|
682
|
+
return False
|
|
683
|
+
|
|
684
|
+
# Register AFTER TypeCodec so EnumType re-matches here: an enum class (Mode,
|
|
685
|
+
# Toggles-style flag enums, etc) is an EnumMeta instance, so its MRO hits
|
|
686
|
+
# EnumType before type. Same render/load/save as any class - the subclass only
|
|
687
|
+
# claims the source render, so mode-backed entries (the inputs tab's mode
|
|
688
|
+
# column) are distinguishable from plain class source at a glance.
|
|
689
|
+
@register_codec(for_type=EnumType)
|
|
690
|
+
class ModeCodec(TypeCodec):
|
|
691
|
+
name = "Enum / Mode"
|
|
692
|
+
# Mode entry kwargs - the mode column of the inputs matrix: purple.
|
|
693
|
+
render_kwargs = {"tint": (0.36, 0.16, 0.50, 0.50)}
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
@register_codec(for_type=types.FunctionType)
|
|
697
|
+
|
|
698
|
+
class FunctionCodec(TypeCodec):
|
|
699
|
+
name = "Python Function"
|
|
700
|
+
# The render function's own source (def + body): green.
|
|
701
|
+
render_kwargs = {"tint": (0.04, 0.45, 0.12, 0.0)}
|
|
702
|
+
|
|
703
|
+
@staticmethod
|
|
704
|
+
def resolve_address(input_value, draw_state=None, **kwargs):
|
|
705
|
+
if isinstance(input_value, types.FunctionType):
|
|
706
|
+
unwrapped = inspect.unwrap(input_value)
|
|
707
|
+
try:
|
|
708
|
+
source_file = inspect.getfile(unwrapped)
|
|
709
|
+
except TypeError:
|
|
710
|
+
return None
|
|
711
|
+
|
|
712
|
+
# Refuse library source - we only ever edit this project's own code.
|
|
713
|
+
if not _is_editable_source(source_file):
|
|
714
|
+
return None
|
|
715
|
+
|
|
716
|
+
if draw_state is not None:
|
|
717
|
+
FileWatch.register_draw_state(draw_state, Path(source_file))
|
|
718
|
+
else:
|
|
719
|
+
return None
|
|
720
|
+
|
|
721
|
+
try:
|
|
722
|
+
mtime = Path(source_file).stat().st_mtime
|
|
723
|
+
except OSError:
|
|
724
|
+
mtime = None
|
|
725
|
+
cached = getattr(draw_state, '_addr_cache', None)
|
|
726
|
+
if cached is not None and cached[0] is input_value and cached[1] == mtime:
|
|
727
|
+
return cached[2]
|
|
728
|
+
|
|
729
|
+
# Same miss-reason timeline as TypeCodec.resolve_address above.
|
|
730
|
+
_why = ("cold" if cached is None
|
|
731
|
+
else "identity" if cached[0] is not input_value
|
|
732
|
+
else f"mtime {cached[1]}->{mtime}")
|
|
733
|
+
_ptrace_rl(("addr-resolve", id(draw_state)), f"addr re-resolve ({_why})",
|
|
734
|
+
target=getattr(input_value, '__name__', '?'))
|
|
735
|
+
_evict_linecache(source_file)
|
|
736
|
+
try:
|
|
737
|
+
source_lines, start_lineno = inspect.getsourcelines(unwrapped)
|
|
738
|
+
|
|
739
|
+
except (OSError, TypeError, tokenize.TokenError, SyntaxError) as e:
|
|
740
|
+
if draw_state._addr_cache is not None:
|
|
741
|
+
return draw_state._addr_cache[2]
|
|
742
|
+
|
|
743
|
+
print(f"[editable_source] could not resolve {getattr(input_value, '__name__', input_value)}: {e}")
|
|
744
|
+
return None
|
|
745
|
+
|
|
746
|
+
# The function may resolve to a DIFFERENT line than its cached address because
|
|
747
|
+
# that's the legitimate sibling-shift case: editing another function in the
|
|
748
|
+
# same file moved this one up/down, and codec.save already patched this
|
|
749
|
+
# function's co_firstlineno (via shift_sibling_linenos) so getsourcelines
|
|
750
|
+
# now returns the truthful new span. Trust it and re-cache the address;
|
|
751
|
+
# rejecting it here would strand any OTHER editor window open on the same
|
|
752
|
+
# file (they'd lose their reference the instant a sibling is edited).
|
|
753
|
+
#
|
|
754
|
+
# But an EXTERNAL edit shifts the file without patching anybody's lineno -
|
|
755
|
+
# findsource then walks back from the stale lineno to whatever def-head
|
|
756
|
+
# line precedes it and hands us a block that ISN'T this function (just the
|
|
757
|
+
# wrong head). Verify the block matches us; if not, re-anchor by ast on the
|
|
758
|
+
# current line (which also heals co_firstlineno), falling back to the LAST
|
|
759
|
+
# good address when the file is mid-edit and won't parse.
|
|
760
|
+
start0 = start_lineno - 1
|
|
761
|
+
span_lines = source_lines
|
|
762
|
+
if not _block_is_function(source_lines, unwrapped.__name__):
|
|
763
|
+
span = _reanchor_function(unwrapped, source_file)
|
|
764
|
+
if span is None:
|
|
765
|
+
if cached is not None:
|
|
766
|
+
return cached[2]
|
|
767
|
+
print(f"[editable_source] {getattr(input_value, '__name__', input_value)} "
|
|
768
|
+
f"not found at its recorded line and could not be re-anchored")
|
|
769
|
+
return None
|
|
770
|
+
start0, end0, span_lines = span
|
|
771
|
+
print(f"[editable_source] re-anchored {unwrapped.__name__} to "
|
|
772
|
+
f"{Path(source_file).name}:{start0 + 1} after external edit")
|
|
773
|
+
elif span_lines and span_lines[0].lstrip().startswith(("def ", "async def")):
|
|
774
|
+
# A DEF-ANCHORED span (a span-recompile sets co_firstlineno to the
|
|
775
|
+
# def line; a full-module compile sets it to the first decorator)
|
|
776
|
+
# EXCLUDES the decorator lines entirely - the @render_func /
|
|
777
|
+
# @defaults source then parses EMPTY and its inputint row shows
|
|
778
|
+
# nothing (the function_dropdown missing-tint-source bug). Re-anchor
|
|
779
|
+
# by ast, which returns the decorator-INCLUSIVE span and heals
|
|
780
|
+
# co_firstlineno; a genuinely undecorated function re-anchors to the
|
|
781
|
+
# wrong lines, so only accept the result when it actually gained a
|
|
782
|
+
# decorator. Cached per (ref, mtime) like the rest of resolve.
|
|
783
|
+
_dspan = _reanchor_function(unwrapped, source_file)
|
|
784
|
+
if (_dspan is not None and _dspan[2]
|
|
785
|
+
and _dspan[2][0].lstrip().startswith("@")):
|
|
786
|
+
start0, _dend0, span_lines = _dspan
|
|
787
|
+
|
|
788
|
+
address = Address(Path(source_file), start0, start0 + len(span_lines),
|
|
789
|
+
source=input_value, watcher_ds=draw_state)
|
|
790
|
+
address._span_fp = _span_fingerprint(span_lines)
|
|
791
|
+
draw_state._addr_cache = (input_value, mtime, address)
|
|
792
|
+
return address
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
@register_codec(for_type=CallSite)
|
|
796
|
+
class CallerCodec(TypeCodec):
|
|
797
|
+
"""Edit the call EXPRESSION at a CallSite — just `foo(...)`, not the statement
|
|
798
|
+
around it.
|
|
799
|
+
|
|
800
|
+
`resolve_address` ast-walks the file for the outermost Call covering the site's
|
|
801
|
+
line (attaching its column span as `_call_cols` and the enclosing function as
|
|
802
|
+
`.source`). `load`/`save` then slice out the bare call using those columns and
|
|
803
|
+
keep the surrounding text (e.g. the `if `/`[0]:` of `if button(...)[0]:`, or the
|
|
804
|
+
`return ` of `return foo()`) as prefix/suffix to splice an edit back between.
|
|
805
|
+
|
|
806
|
+
Why the call expression and not the whole line (like FunctionCodec's def span):
|
|
807
|
+
a statement HEADER (`if foo():`, `for x in foo():`) or a non-module-level
|
|
808
|
+
statement is not independently parseable — wrapping it in a dummy function
|
|
809
|
+
doesn't help (`if foo():` still needs a body). A bare call expression always
|
|
810
|
+
parses, surfaces as an editable CallParse, and round-trips cleanly. The
|
|
811
|
+
prefix/suffix are stable (the user edits only the call), so they stay valid even
|
|
812
|
+
through half-typed states where the call's columns drift."""
|
|
813
|
+
name = "Python Call Site"
|
|
814
|
+
# Caller kwargs at the call site: the call is teal.
|
|
815
|
+
render_kwargs = {"tint": (0.05, 0.14, 0.20, 0.60)}
|
|
816
|
+
|
|
817
|
+
@staticmethod
|
|
818
|
+
def resolve_address(input_value, draw_state=None, **kwargs):
|
|
819
|
+
if not isinstance(input_value, CallSite):
|
|
820
|
+
return None
|
|
821
|
+
filename, lineno = input_value.filename, input_value.lineno
|
|
822
|
+
if filename is None or lineno is None:
|
|
823
|
+
return None
|
|
824
|
+
|
|
825
|
+
# Refuse library source - we only ever edit this project's own code.
|
|
826
|
+
if not _is_editable_source(filename):
|
|
827
|
+
return None
|
|
828
|
+
|
|
829
|
+
path = Path(filename)
|
|
830
|
+
if draw_state is not None:
|
|
831
|
+
FileWatch.register_draw_state(draw_state, path)
|
|
832
|
+
|
|
833
|
+
try:
|
|
834
|
+
mtime = path.stat().st_mtime
|
|
835
|
+
except OSError:
|
|
836
|
+
mtime = None
|
|
837
|
+
# Cache by (filename, lineno, mtime) - a fresh CallSite is passed each frame,
|
|
838
|
+
# so compare by VALUE, not identity (FunctionCodec can use `is` on the stable
|
|
839
|
+
# function object; we don't). Re-resolves only when the file changes.
|
|
840
|
+
cached = getattr(draw_state, '_addr_cache', None)
|
|
841
|
+
if cached is not None and cached[0] == (filename, lineno) and cached[1] == mtime:
|
|
842
|
+
return cached[2]
|
|
843
|
+
|
|
844
|
+
_evict_linecache(str(path))
|
|
845
|
+
# ast.walk + loop (in C, single-digit ms) finds the call span and attaches
|
|
846
|
+
# the enclosing function as .source - see _resolve_call_address. NOT libcst's
|
|
847
|
+
# PositionTracker, which is O(whole file) pure-Python and stalls the loop.
|
|
848
|
+
address = _resolve_call_address((filename, lineno))
|
|
849
|
+
|
|
850
|
+
# _resolve_call_address NEVER raises and never returns None - when ast.parse
|
|
851
|
+
# fails (the file is mid-edit / syntactically invalid as the user types), it
|
|
852
|
+
# returns a degenerate ONE-LINE fallback span with no `_call_cols`. Saving
|
|
853
|
+
# the multi-line buffer into that 1-line span splices the extra lines IN,
|
|
854
|
+
# appending a copy of the call's continuation lines on every keystroke-save.
|
|
855
|
+
# So treat a missing `_call_cols` as "could not resolve" and keep the last
|
|
856
|
+
# good address - its span matches what our last save wrote, so the next save
|
|
857
|
+
# replaces in place. Mirrors FunctionCodec's getsourcelines-raised guard;
|
|
858
|
+
# there, resolve error surfaces as an exception, here as a flag and span.
|
|
859
|
+
resolved = isinstance(address, Address) and getattr(address, "_call_cols", None) is not None
|
|
860
|
+
if not resolved:
|
|
861
|
+
return cached[2] if cached is not None else None
|
|
862
|
+
|
|
863
|
+
# _resolve_call_address sets the call's prefix/suffix on every valid resolve.
|
|
864
|
+
# As a safety net (e.g. if its inner split ever fails), carry forward the
|
|
865
|
+
# last good ones - they're stable (the user edits only the call, never the
|
|
866
|
+
# `if `/`[0]:` around it), so they stay valid across re-resolves.
|
|
867
|
+
if (not hasattr(address, "_call_prefix")
|
|
868
|
+
and cached is not None and hasattr(cached[2], "_call_prefix")):
|
|
869
|
+
address._call_prefix = cached[2]._call_prefix
|
|
870
|
+
address._call_suffix = cached[2]._call_suffix
|
|
871
|
+
|
|
872
|
+
address._watcher_ds = draw_state
|
|
873
|
+
if draw_state is not None:
|
|
874
|
+
draw_state._addr_cache = ((filename, lineno), mtime, address)
|
|
875
|
+
return address
|
|
876
|
+
|
|
877
|
+
@staticmethod
|
|
878
|
+
def load(address, source_text=None, **kwargs):
|
|
879
|
+
"""Load just the call EXPRESSION (not the whole statement). Slice the bare
|
|
880
|
+
call out of its line span using `_call_cols`, and stash the surrounding
|
|
881
|
+
prefix/suffix on the address so save can splice an edit back between them.
|
|
882
|
+
The bare call always parses; the statement around it may not."""
|
|
883
|
+
text, newline = _source_and_newline(address, source_text)
|
|
884
|
+
span_lines = text.split(newline)[address.start:address.end]
|
|
885
|
+
# Conflict-guard baseline (see TypeCodec.save): the FULL span as loaded -
|
|
886
|
+
# save rebuilds prefix + call + suffix, so it verifies at line level.
|
|
887
|
+
address._span_fp = _span_fingerprint(span_lines)
|
|
888
|
+
cols = getattr(address, "_call_cols", None)
|
|
889
|
+
if cols is not None and span_lines:
|
|
890
|
+
prefix, call_text, suffix = _split_span_at_call(span_lines, cols[0], cols[1], newline)
|
|
891
|
+
address._call_prefix = prefix
|
|
892
|
+
address._call_suffix = suffix
|
|
893
|
+
return call_text
|
|
894
|
+
# No column info (resolution was a line fallback) - no span, no splice.
|
|
895
|
+
address._call_prefix = ""
|
|
896
|
+
address._call_suffix = ""
|
|
897
|
+
return newline.join(span_lines)
|
|
898
|
+
|
|
899
|
+
@staticmethod
|
|
900
|
+
def save(address, data, ensure_import=None, **kwargs):
|
|
901
|
+
"""Splice the edited call expression back between its stored prefix/suffix
|
|
902
|
+
(captured by load), reconstructing the full statement, then write the whole
|
|
903
|
+
span via TypeCodec.save (which handles the line splice + sibling shift)."""
|
|
904
|
+
prefix = getattr(address, "_call_prefix", "")
|
|
905
|
+
suffix = getattr(address, "_call_suffix", "")
|
|
906
|
+
newline = _detect_newline(address.path.read_bytes())
|
|
907
|
+
# Drop one trailing newline the editor / cst round-trip may append, so the
|
|
908
|
+
# suffix doesn't get pushed onto a phantom next line.
|
|
909
|
+
if data.endswith(newline):
|
|
910
|
+
data = data[:-len(newline)]
|
|
911
|
+
elif data.endswith("\n"):
|
|
912
|
+
data = data[:-1]
|
|
913
|
+
full = prefix + data + suffix
|
|
914
|
+
return TypeCodec.save(address=address, data=full, ensure_import=ensure_import, **kwargs)
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def _resolve_decoration_span(target):
|
|
918
|
+
"""((path, deco_start, deco_end), unwrapped) for the DECORATOR block above a
|
|
919
|
+
class/function — 0-based [start, end) file lines covering only the `@...`
|
|
920
|
+
lines, NOT the def/class or its body.
|
|
921
|
+
|
|
922
|
+
ast-parses the dedented object source (decorators + def, always parseable) to
|
|
923
|
+
get each decorator's precise line span, so a multi-line `@deco(\n ...\n)` is
|
|
924
|
+
covered exactly. When the object has NO decorators, returns a zero-length span
|
|
925
|
+
pinned to the def/class line, so a save splices a brand-new decorator in right
|
|
926
|
+
above it (the "+ add" path). Raises on an unreadable/invalid file (caller keeps
|
|
927
|
+
the last good address, mirroring FunctionCodec)."""
|
|
928
|
+
unwrapped = inspect.unwrap(target) if isinstance(target, types.FunctionType) else target
|
|
929
|
+
source_file = inspect.getfile(unwrapped)
|
|
930
|
+
_evict_linecache(source_file)
|
|
931
|
+
# getsourcelines starts at the FIRST decorator (1-based start_lineno) for a
|
|
932
|
+
# decorated object, or at the def/class line when undecorated.
|
|
933
|
+
source_lines, start_lineno = inspect.getsourcelines(unwrapped)
|
|
934
|
+
tree = ast.parse(textwrap.dedent("".join(source_lines)))
|
|
935
|
+
node = tree.body[0] # the def/class - block coords are 1-based, line 1 = source_lines[0]
|
|
936
|
+
decos = getattr(node, "decorator_list", [])
|
|
937
|
+
base0 = start_lineno - 1 # file line (0-based) of source_lines[0]
|
|
938
|
+
if decos:
|
|
939
|
+
first = min(d.lineno for d in decos)
|
|
940
|
+
last = max(getattr(d, "end_lineno", d.lineno) for d in decos)
|
|
941
|
+
deco_start, deco_end = base0 + (first - 1), base0 + last
|
|
942
|
+
else:
|
|
943
|
+
pos = base0 + (node.lineno - 1) # the def/class line - empty span above it
|
|
944
|
+
deco_start = deco_end = pos
|
|
945
|
+
return (Path(source_file), deco_start, deco_end), unwrapped
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
@register_codec(for_type=Decorations)
|
|
949
|
+
class DecorationsCodec(TypeCodec):
|
|
950
|
+
"""Edit the DECORATOR block above a class or function — just the `@...` lines.
|
|
951
|
+
|
|
952
|
+
Like `CallerCodec`, only `resolve_address` differs from `TypeCodec`: it points
|
|
953
|
+
the Address at the decorator lines instead of the whole def/class. `load`/`save`
|
|
954
|
+
are inherited and operate on that line span as text (the decorators are full
|
|
955
|
+
lines, so no column splicing is needed — unlike a call embedded in a larger
|
|
956
|
+
statement). `.source` is the wrapped target object, so the inherited save shifts
|
|
957
|
+
siblings correctly; recompile re-runs the decorators by rebuilding the whole
|
|
958
|
+
object (`_recompile_decorations`).
|
|
959
|
+
|
|
960
|
+
An undecorated target resolves to a zero-length span pinned above its def/class
|
|
961
|
+
line: load returns "", and saving typed text splices a fresh decorator in."""
|
|
962
|
+
name = "Python Decorations"
|
|
963
|
+
# Decorator blocks (@window / @render_func / @defaults): lighter blue.
|
|
964
|
+
render_kwargs = {"tint": (0.20, 0.42, 0.75, 0.50)}
|
|
965
|
+
|
|
966
|
+
@staticmethod
|
|
967
|
+
def resolve_address(input_value, draw_state=None, **kwargs):
|
|
968
|
+
if not isinstance(input_value, Decorations):
|
|
969
|
+
return None
|
|
970
|
+
# A runtime-generated bubbling subclass has no source; resolve its base.
|
|
971
|
+
target = base_of_bubbling(input_value.target)
|
|
972
|
+
if not isinstance(target, (type, types.FunctionType)):
|
|
973
|
+
return None
|
|
974
|
+
try:
|
|
975
|
+
source_file = inspect.getfile(
|
|
976
|
+
inspect.unwrap(target) if isinstance(target, types.FunctionType) else target)
|
|
977
|
+
except TypeError:
|
|
978
|
+
return None
|
|
979
|
+
# Refuse library source - we only ever edit this project's own code.
|
|
980
|
+
if not _is_editable_source(source_file):
|
|
981
|
+
return None
|
|
982
|
+
|
|
983
|
+
path = Path(source_file)
|
|
984
|
+
if draw_state is not None:
|
|
985
|
+
FileWatch.register_draw_state(draw_state, path)
|
|
986
|
+
|
|
987
|
+
try:
|
|
988
|
+
mtime = path.stat().st_mtime
|
|
989
|
+
except OSError:
|
|
990
|
+
mtime = None
|
|
991
|
+
# Cache by (target, mtime): a fresh Decorations wraps the SAME stable target
|
|
992
|
+
# each frame, so compare the target by identity (like FunctionCodec). Re-
|
|
993
|
+
# resolves only if the file changes.
|
|
994
|
+
cached = getattr(draw_state, "_addr_cache", None)
|
|
995
|
+
if cached is not None and cached[0] is target and cached[1] == mtime:
|
|
996
|
+
return cached[2]
|
|
997
|
+
|
|
998
|
+
try:
|
|
999
|
+
(p, start, end), _unwrapped = _resolve_decoration_span(target)
|
|
1000
|
+
except (OSError, TypeError, tokenize.TokenError, SyntaxError, ValueError) as e:
|
|
1001
|
+
# File mid-edit / unreadable - keep the last good address so a save
|
|
1002
|
+
# replaces in-place rather than writing to a bad span. Mirrors
|
|
1003
|
+
# FunctionCodec's getsourcelines fallback.
|
|
1004
|
+
if cached is not None:
|
|
1005
|
+
return cached[2]
|
|
1006
|
+
print(f"[DecorationsCodec] could not resolve {getattr(target, '__name__', target)}: {e}")
|
|
1007
|
+
return None
|
|
1008
|
+
|
|
1009
|
+
address = Address(p, start, end, source=target, watcher_ds=draw_state)
|
|
1010
|
+
if draw_state is not None:
|
|
1011
|
+
draw_state._addr_cache = (target, mtime, address)
|
|
1012
|
+
return address
|
|
1013
|
+
|
|
1014
|
+
|
|
1015
|
+
@register_codec(for_type=types.ModuleType)
|
|
1016
|
+
class ModuleCodec(TypeCodec):
|
|
1017
|
+
name = "Python Module"
|
|
1018
|
+
# No source highlighting yet (don't inherit TypeCodec's class blue tint).
|
|
1019
|
+
render_kwargs = {}
|
|
1020
|
+
|
|
1021
|
+
@staticmethod
|
|
1022
|
+
def resolve_address(input_value, draw_state=None, **kwargs):
|
|
1023
|
+
if not isinstance(input_value, types.ModuleType):
|
|
1024
|
+
return None
|
|
1025
|
+
source_file = Path(input_value.__file__)
|
|
1026
|
+
# Refuse library source - we only ever edit this project's own code.
|
|
1027
|
+
if not _is_editable_source(source_file):
|
|
1028
|
+
return None
|
|
1029
|
+
if draw_state is not None:
|
|
1030
|
+
FileWatch.register_draw_state(draw_state, source_file)
|
|
1031
|
+
return Address(source_file, source=input_value, watcher_ds=draw_state)
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
@register_codec(ext=(".py", ".md", ".txt", ".json", ".toml", ".yaml", ".yml",
|
|
1035
|
+
".sh", ".cfg", ".ini", ".glsl", ".frag", ".vert"))
|
|
1036
|
+
class TextFileCodec(TypeCodec):
|
|
1037
|
+
"""Whole-file editing for a plain text Path — the no-span Address case.
|
|
1038
|
+
|
|
1039
|
+
A spanless Address (start/end None) already means "the whole file" to the
|
|
1040
|
+
inherited TypeCodec load/save, so this codec is just address resolution:
|
|
1041
|
+
point at the file, register the watcher, cache by mtime."""
|
|
1042
|
+
name = "Text File"
|
|
1043
|
+
|
|
1044
|
+
@staticmethod
|
|
1045
|
+
def show_code_buttons(address):
|
|
1046
|
+
# One codec serves any text extension - only .py is runnable/importable.
|
|
1047
|
+
return address is not None and address.path.suffix.lower() == ".py"
|
|
1048
|
+
|
|
1049
|
+
@staticmethod
|
|
1050
|
+
def resolve_address(input_value, draw_state=None, **kwargs):
|
|
1051
|
+
path = Path(str(input_value))
|
|
1052
|
+
# Whole-file text editing follows the gentler gate: folder windows
|
|
1053
|
+
# mount dirs outside the project, so anywhere under $HOME works (for
|
|
1054
|
+
# library installs) - not just the project tree.
|
|
1055
|
+
if not is_writable_file(path) or not path.is_file():
|
|
1056
|
+
return None
|
|
1057
|
+
if draw_state is not None:
|
|
1058
|
+
FileWatch.register_draw_state(draw_state, path)
|
|
1059
|
+
try:
|
|
1060
|
+
mtime = path.stat().st_mtime
|
|
1061
|
+
except OSError:
|
|
1062
|
+
mtime = None
|
|
1063
|
+
cached = getattr(draw_state, '_addr_cache', None)
|
|
1064
|
+
if cached is not None and cached[0] == input_value and cached[1] == mtime:
|
|
1065
|
+
return cached[2]
|
|
1066
|
+
address = Address(path, source=input_value, watcher_ds=draw_state)
|
|
1067
|
+
address._allow_write = True # resolved via is_writable_file - see save() guard
|
|
1068
|
+
if draw_state is not None:
|
|
1069
|
+
draw_state._addr_cache = (input_value, mtime, address)
|
|
1070
|
+
return address
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
def _resolve_plain_file(input_value, draw_state, **kwargs):
|
|
1074
|
+
"""Shared resolve for read-only plain-file codecs (images, binaries):
|
|
1075
|
+
is_writable_file gate and the (input, mtime) address cache — the same
|
|
1076
|
+
shape as TextFileCodec.resolve_address MINUS the FileWatch registration.
|
|
1077
|
+
These files never join the inotify watch: registering MD5s the whole
|
|
1078
|
+
file as a change baseline (a full read of every opened image) and
|
|
1079
|
+
schedules the file's directory as its own inotify INSTANCE (watchdog
|
|
1080
|
+
opens one per scheduled dir; the per-user cap is 128, shared with every
|
|
1081
|
+
other app). External changes are still noticed — code_file_io stats
|
|
1082
|
+
mtime/size every body run (CodeState.is_file_stale) and, for a
|
|
1083
|
+
read-only codec, reloads outright."""
|
|
1084
|
+
path = Path(str(input_value))
|
|
1085
|
+
if not is_writable_file(path) or not path.is_file():
|
|
1086
|
+
return None
|
|
1087
|
+
try:
|
|
1088
|
+
mtime = path.stat().st_mtime
|
|
1089
|
+
except OSError:
|
|
1090
|
+
mtime = None
|
|
1091
|
+
cached = getattr(draw_state, '_addr_cache', None)
|
|
1092
|
+
if cached is not None and cached[0] == input_value and cached[1] == mtime:
|
|
1093
|
+
return cached[2]
|
|
1094
|
+
address = Address(path, source=input_value, watcher_ds=draw_state)
|
|
1095
|
+
if draw_state is not None:
|
|
1096
|
+
draw_state._addr_cache = (input_value, mtime, address)
|
|
1097
|
+
return address
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
@register_codec(ext=(".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".tga"))
|
|
1101
|
+
class ImageCodec(Codec):
|
|
1102
|
+
"""Image Path → PendingTexture. load() runs on code_file_io's background
|
|
1103
|
+
thread, so it only DECODES (PIL, safe off-thread) and returns a
|
|
1104
|
+
PendingTexture; core_render's wrapper calls pending_upload() on the GL
|
|
1105
|
+
thread the first frame it renders, and draw_pending_texture (the type's
|
|
1106
|
+
default renderer) draws the uploaded texture with zoom/pan. Read-only
|
|
1107
|
+
(editable=False): view edits never dirty the host and save() refuses."""
|
|
1108
|
+
|
|
1109
|
+
name = "Image"
|
|
1110
|
+
# PendingTexture has a default renderer (draw_pending_texture is
|
|
1111
|
+
# register_default_for_type), so no draw_func: type routing finds it.
|
|
1112
|
+
editable = False
|
|
1113
|
+
icon = "\uf03e" # FA image
|
|
1114
|
+
resolve_address = staticmethod(_resolve_plain_file)
|
|
1115
|
+
|
|
1116
|
+
# claims() runs in the render thread (codec_for_path is re-ried ever
|
|
1117
|
+
# frame), so the PIL header probe is memoized per path and only re-runs
|
|
1118
|
+
# when mtime or size moves - the same staleness key resolve_address uses.
|
|
1119
|
+
_claims_cache = {}
|
|
1120
|
+
|
|
1121
|
+
@classmethod
|
|
1122
|
+
def claims(cls, path):
|
|
1123
|
+
try:
|
|
1124
|
+
s = path.stat()
|
|
1125
|
+
except OSError:
|
|
1126
|
+
# Missing/unreadable: accept and let resolve_address return None -
|
|
1127
|
+
# claims veto is only about content, not existence.
|
|
1128
|
+
return True
|
|
1129
|
+
cached = cls._claims_cache.get(str(path))
|
|
1130
|
+
if cached is not None and cached[:2] == (s.st_mtime, s.st_size):
|
|
1131
|
+
return cached[2]
|
|
1132
|
+
try:
|
|
1133
|
+
# Lazy open parses just the header - cheap, no pixel decode.
|
|
1134
|
+
with Image.open(path):
|
|
1135
|
+
ok = True
|
|
1136
|
+
except Exception:
|
|
1137
|
+
ok = False
|
|
1138
|
+
cls._claims_cache[str(path)] = (s.st_mtime, s.st_size, ok)
|
|
1139
|
+
return ok
|
|
1140
|
+
|
|
1141
|
+
@staticmethod
|
|
1142
|
+
def load(address, **kwargs):
|
|
1143
|
+
path = address.path
|
|
1144
|
+
return ImageCodec.decode_bytes(path.read_bytes(), str(path))
|
|
1145
|
+
|
|
1146
|
+
@staticmethod
|
|
1147
|
+
def decode_bytes(raw, key):
|
|
1148
|
+
"""PIL-decode `raw` into a PendingTexture registered under `key` —
|
|
1149
|
+
load()'s body, shared with the code editor's compare pane, which
|
|
1150
|
+
renders a git BLOB of the image (bytes that never exist on disk;
|
|
1151
|
+
the caller keys those per (path, reference) so they never clobber
|
|
1152
|
+
the live file's texture)."""
|
|
1153
|
+
image = Image.open(io.BytesIO(raw))
|
|
1154
|
+
if image.mode == "P":
|
|
1155
|
+
image = image.convert("RGBA" if "transparency" in image.info else "RGB")
|
|
1156
|
+
elif image.mode == "1":
|
|
1157
|
+
image = image.convert("L")
|
|
1158
|
+
elif image.mode not in PIL_TO_GL_FORMAT:
|
|
1159
|
+
image = image.convert("RGBA")
|
|
1160
|
+
image = image.transpose(Image.FLIP_TOP_BOTTOM)
|
|
1161
|
+
|
|
1162
|
+
width, height = image.size
|
|
1163
|
+
pending = PendingTexture(name=key, tex_width=width, tex_height=height,
|
|
1164
|
+
gl_format=PIL_TO_GL_FORMAT[image.mode],
|
|
1165
|
+
data=image.tobytes())
|
|
1166
|
+
# Registers with the manager (dedupes against an already-uploaded
|
|
1167
|
+
# texture for this path); the GL upload itself happens on the render
|
|
1168
|
+
# thread via the wrapper's pending_upload() hook.
|
|
1169
|
+
Core.melty.texture_manager.put_pending(key, pending)
|
|
1170
|
+
return pending
|
|
1171
|
+
|
|
1172
|
+
@staticmethod
|
|
1173
|
+
def save(*args, **kwargs):
|
|
1174
|
+
return False
|
|
1175
|
+
|
|
1176
|
+
|
|
1177
|
+
class BinaryFileCodec(Codec):
|
|
1178
|
+
"""Read-only fallback for files no other codec claims (unknown extension /
|
|
1179
|
+
no extension, content sniffed as binary): show metadata and a short hex
|
|
1180
|
+
preview instead of "No codec". Reached via codec_for_path, never the
|
|
1181
|
+
extension registry. save() refuses — the summary is a VIEW of the bytes,
|
|
1182
|
+
and writing it back would replace the file with its own hexdump."""
|
|
1183
|
+
|
|
1184
|
+
name = "Binary File"
|
|
1185
|
+
# The summary is a str, so the caller's text view renders it - but it is
|
|
1186
|
+
# a VIEW of the bytes, never written back.
|
|
1187
|
+
editable = False
|
|
1188
|
+
resolve_address = staticmethod(_resolve_plain_file)
|
|
1189
|
+
|
|
1190
|
+
PREVIEW_BYTES = 256
|
|
1191
|
+
|
|
1192
|
+
@staticmethod
|
|
1193
|
+
def load(address, **kwargs):
|
|
1194
|
+
path = address.path
|
|
1195
|
+
size = path.stat().st_size
|
|
1196
|
+
kind, _ = mimetypes.guess_type(path.name)
|
|
1197
|
+
head = path.open("rb").read(BinaryFileCodec.PREVIEW_BYTES)
|
|
1198
|
+
|
|
1199
|
+
lines = [f"{path.name} — {size:,} bytes ({kind or 'unknown type'})", ""]
|
|
1200
|
+
for off in range(0, len(head), 16):
|
|
1201
|
+
row = head[off:off + 16]
|
|
1202
|
+
hx = " ".join(f"{b:02x}" for b in row)
|
|
1203
|
+
ascii_ = "".join(chr(b) if 32 <= b < 127 else "." for b in row)
|
|
1204
|
+
lines.append(f"{off:08x} {hx:<47} {ascii_}")
|
|
1205
|
+
if size > len(head):
|
|
1206
|
+
lines.append(f"… {size - len(head):,} more bytes")
|
|
1207
|
+
return "\n".join(lines)
|
|
1208
|
+
|
|
1209
|
+
@staticmethod
|
|
1210
|
+
def save(*args, **kwargs):
|
|
1211
|
+
return False
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
def codec_for_path(path):
|
|
1215
|
+
"""Every real file resolves to SOME codec: registered extension first,
|
|
1216
|
+
else sniff the head — NUL-free utf-8 edits as text (TextFileCodec),
|
|
1217
|
+
anything else gets the read-only binary summary. This is what lets the
|
|
1218
|
+
folder windows mount a stress-test directory full of extensionless blobs
|
|
1219
|
+
without a wall of "No codec" rows.
|
|
1220
|
+
|
|
1221
|
+
The extension match is subject to the codec's content veto (claims): a
|
|
1222
|
+
file whose bytes don't decode as the extension promises (an empty or
|
|
1223
|
+
corrupt .png) falls through to the sniff instead of being routed to a
|
|
1224
|
+
load() that can only throw."""
|
|
1225
|
+
if isinstance(path, str) and len(path) > 255:
|
|
1226
|
+
return None
|
|
1227
|
+
|
|
1228
|
+
codec = extension_to_codec.get(path.suffix.lower())
|
|
1229
|
+
if codec is not None and codec.claims(path):
|
|
1230
|
+
return codec
|
|
1231
|
+
if not path.is_file():
|
|
1232
|
+
return None
|
|
1233
|
+
try:
|
|
1234
|
+
head = path.open("rb").read(4096)
|
|
1235
|
+
except OSError:
|
|
1236
|
+
return None
|
|
1237
|
+
if b"\0" in head:
|
|
1238
|
+
return BinaryFileCodec
|
|
1239
|
+
try:
|
|
1240
|
+
head.decode("utf-8")
|
|
1241
|
+
except UnicodeDecodeError:
|
|
1242
|
+
# A multi-byte char split at the 4096 boundary technically lands here -
|
|
1243
|
+
# acceptable: the file still renders, just as the binary summary.
|
|
1244
|
+
return BinaryFileCodec
|
|
1245
|
+
return TextFileCodec
|
|
1246
|
+
|
|
1247
|
+
|
|
1248
|
+
def asset_extensions():
|
|
1249
|
+
"""Registered extensions whose codec loads something OTHER than editor
|
|
1250
|
+
text — images today, a .npy codec tomorrow. This is the non-Python file
|
|
1251
|
+
set global search's Code tab lists beside the loaded modules: register
|
|
1252
|
+
a codec with `ext=` and its files become searchable/openable, nothing
|
|
1253
|
+
else to wire. Lowercase, dotted (".png"), as the registry stores them."""
|
|
1254
|
+
return {ext for ext, codec in extension_to_codec.items()
|
|
1255
|
+
if codec is not TextFileCodec}
|