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,2209 @@
|
|
|
1
|
+
"""Static "will it run" checks — undefined names + call-signature mismatches.
|
|
2
|
+
|
|
3
|
+
`check_source(text, path=None)` -> [(line, message)], 1-based lines aligned with
|
|
4
|
+
the buffer. Runs on chain_in's background thread AFTER libcst and compile()
|
|
5
|
+
both passed (see _run_chain_in), so the source is known syntactically valid and
|
|
6
|
+
this pass only adds the NameError / TypeError / AttributeError class of
|
|
7
|
+
mistakes those let through:
|
|
8
|
+
|
|
9
|
+
* a Name load that no enclosing scope, builtin, or live-module global binds
|
|
10
|
+
("name 'myvarr' is not defined"). When the name is satisfiable by an
|
|
11
|
+
import — an importable top-level module, or a name other live modules got
|
|
12
|
+
from an import (`np`, `Path`, ...) — the message carries the exact fixing
|
|
13
|
+
statement after a "missing import:" marker, e.g.
|
|
14
|
+
"name 'np' is not defined — missing import: import numpy as np", so the
|
|
15
|
+
editor can both style it distinctly and offer the auto-import fix.
|
|
16
|
+
* a call to a function/class DEFINED IN THIS BUFFER whose arguments can't
|
|
17
|
+
bind (unknown kwarg, too many positionals, missing required args)
|
|
18
|
+
* the same signature check against LIVE objects — a bare imported name
|
|
19
|
+
(`request_render(1, 2, 3)`), a builtin (`isinstance(x)`), or a dotted
|
|
20
|
+
module attribute (`imgui.dummy()`), resolved through the running process's
|
|
21
|
+
module for `path`. C/Cython callables that hide their signature from
|
|
22
|
+
inspect fall back to the embedsignature doc line ("dummy(width, height)").
|
|
23
|
+
When the callee's DEFINING file has unsaved edits, the expected signature
|
|
24
|
+
comes from that file's pending text instead of the live object (which
|
|
25
|
+
reflects the last compile) — see the pending-truth signature section.
|
|
26
|
+
Span buffers (only_missing_imports) run the same call check through the
|
|
27
|
+
enclosing module's pending text (_check_call_span).
|
|
28
|
+
* a missing attribute on a live MODULE (`imgui.dummyy`) — chains only ever
|
|
29
|
+
walk through module objects, never arbitrary instances
|
|
30
|
+
|
|
31
|
+
Every rule errs on SILENCE — a missed problem beats a false alarm in an editor
|
|
32
|
+
that flags while you type:
|
|
33
|
+
|
|
34
|
+
* `from x import *` anywhere disables the name pass for the whole buffer
|
|
35
|
+
* signature checks skip decorated defs (a decorator can change the signature
|
|
36
|
+
arbitrarily — and @render_func always does), rebound/duplicated names, and
|
|
37
|
+
calls through anything we can't pin to a def in the buffer or a live object
|
|
38
|
+
* live objects carrying `__wrapped__` are skipped — inspect.signature follows
|
|
39
|
+
the wrap, but the wrapper may inject arguments (render_func's draw_state)
|
|
40
|
+
* a call using *args / **kwargs expansion skips the counts it makes unknowable
|
|
41
|
+
* annotations are never name-checked (string/forward refs, future-import)
|
|
42
|
+
* `'name' in globals()` guards bind the tested name; a try body whose except
|
|
43
|
+
catches NameError suppresses name checks, TypeError/AttributeError ones
|
|
44
|
+
suppress signature/attribute checks (probing is the handled case)
|
|
45
|
+
* missing-attr checks skip modules with a PEP 562 `__getattr__`, dotted paths
|
|
46
|
+
the buffer itself imports (`import a.b` ⇒ `a.b` will exist), and attrs the
|
|
47
|
+
buffer assigns (`mod.flag = True` earlier in the file)
|
|
48
|
+
* scope analysis is flow-insensitive: module-level code may legally use names
|
|
49
|
+
defined later (function bodies run later), so order is ignored
|
|
50
|
+
|
|
51
|
+
Name resolution follows Python's actual rule — local scope, enclosing FUNCTION
|
|
52
|
+
scopes (class scopes are invisible to nested functions), module, builtins —
|
|
53
|
+
plus one editor-specific fallback: the LIVE module's namespace when `path` maps
|
|
54
|
+
to an entry in sys.modules, so names a hotswap/exec injected at runtime don't
|
|
55
|
+
flag even though no static binding exists.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
import ast
|
|
59
|
+
import builtins
|
|
60
|
+
import inspect
|
|
61
|
+
import os
|
|
62
|
+
import sys
|
|
63
|
+
import time
|
|
64
|
+
import types
|
|
65
|
+
|
|
66
|
+
from meltygui.core.diagnostics.notifications import lag_traced
|
|
67
|
+
|
|
68
|
+
# Names every module/frame sees without a visible binding.
|
|
69
|
+
_BUILTIN_NAMES = frozenset(dir(builtins)) | {
|
|
70
|
+
"__file__", "__name__", "__doc__", "__package__", "__spec__",
|
|
71
|
+
"__loader__", "__builtins__", "__debug__", "__path__", "__class__",
|
|
72
|
+
"__annotations__", "__dict__", "__module__", "__qualname__",
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
_MISS = object()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class _Scope:
|
|
79
|
+
|
|
80
|
+
def __init__(self, kind, parent):
|
|
81
|
+
self.kind = kind # 'module' | 'function' | 'class' | 'comp'
|
|
82
|
+
self.parent = parent
|
|
83
|
+
self.binds = set() # every name bound somewhere in this scope
|
|
84
|
+
self.import_binds = set() # names bound by an import statement
|
|
85
|
+
self.other_binds = set() # names bound by anything else
|
|
86
|
+
self.ambiguous = set() # rebound names - excluded from signature checks
|
|
87
|
+
self.defs = {} # name -> (FunctionDef, flavor) - undecorated, bound once
|
|
88
|
+
self.classes = {} # name -> (ClassDef, class _Scope) - undecorated, bound once
|
|
89
|
+
self.loads = [] # (name, lineno) Name loads made in this scope
|
|
90
|
+
self.global_names = set() # names declared `global` here
|
|
91
|
+
self.self_name = None # method scopes: the first arg ('self'/'cls')
|
|
92
|
+
|
|
93
|
+
def bind(self, name, is_import=False):
|
|
94
|
+
if name in self.binds:
|
|
95
|
+
# A second binding makes "which def is this name" unknowable for the
|
|
96
|
+
# signature pass; the name pass only needs set membership.
|
|
97
|
+
self.defs.pop(name, None)
|
|
98
|
+
self.classes.pop(name, None)
|
|
99
|
+
self.ambiguous.add(name)
|
|
100
|
+
else:
|
|
101
|
+
self.binds.add(name)
|
|
102
|
+
(self.import_binds if is_import else self.other_binds).add(name)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# Project decorator conventions, matched BY NAME (bare or called). This is a
|
|
106
|
+
# project-specific lint, so the names are trusted without resolving them:
|
|
107
|
+
# * transparent - leave the function UNCHANGED (window_decoration.window,
|
|
108
|
+
# core_decoration.defaults and app.glfw_window only register), so the
|
|
109
|
+
# def's own signature covers the calling code, in any order relative
|
|
110
|
+
# to @render_func.
|
|
111
|
+
# * wrapper — @render_func replaces the def with core_render's
|
|
112
|
+
# `wrapper(input_value=None, **kwargs)`: at most ONE positional, any
|
|
113
|
+
# kwarg accepted (modes/defaults/comment-args may fill required params,
|
|
114
|
+
# so only the positional shape is checkable).
|
|
115
|
+
_TRANSPARENT_DECORATORS = frozenset({"window", "defaults", "glfw_window"})
|
|
116
|
+
_WRAPPER_DECORATORS = frozenset({"render_func"})
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _decorator_name(dec):
|
|
120
|
+
"""The bare name a decorator is spelled with (`@window` / `@window(...)`),
|
|
121
|
+
or None for anything dotted/complex."""
|
|
122
|
+
f = dec.func if isinstance(dec, ast.Call) else dec
|
|
123
|
+
return f.id if isinstance(f, ast.Name) else None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _decorator_flavor(decorator_list):
|
|
127
|
+
"""'plain' / 'static' / 'class' when the signature is still trustworthy,
|
|
128
|
+
None when a decorator could have changed it. Besides the two builtins,
|
|
129
|
+
transparent project decorators (@window) keep the def's real signature."""
|
|
130
|
+
if not decorator_list:
|
|
131
|
+
return "plain"
|
|
132
|
+
if len(decorator_list) == 1 and isinstance(decorator_list[0], ast.Name):
|
|
133
|
+
if decorator_list[0].id == "staticmethod":
|
|
134
|
+
return "static"
|
|
135
|
+
if decorator_list[0].id == "classmethod":
|
|
136
|
+
return "class"
|
|
137
|
+
if all(_decorator_name(d) in _TRANSPARENT_DECORATORS
|
|
138
|
+
for d in decorator_list):
|
|
139
|
+
return "plain"
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _unwind_chain(node):
|
|
144
|
+
"""node and its .value ancestry as (base Name, [(attr, node)…]) when the
|
|
145
|
+
whole chain is Name.attr.attr…, else (None, None)."""
|
|
146
|
+
attrs = []
|
|
147
|
+
cur = node
|
|
148
|
+
while isinstance(cur, ast.Attribute):
|
|
149
|
+
attrs.append((cur.attr, cur))
|
|
150
|
+
cur = cur.value
|
|
151
|
+
if isinstance(cur, ast.Name) and isinstance(cur.ctx, ast.Load):
|
|
152
|
+
attrs.reverse()
|
|
153
|
+
return cur, attrs
|
|
154
|
+
return None, None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _handler_catches(handler_type, names):
|
|
158
|
+
if handler_type is None:
|
|
159
|
+
return True # bare except
|
|
160
|
+
if isinstance(handler_type, ast.Tuple):
|
|
161
|
+
return any(_handler_catches(e, names) for e in handler_type.elts)
|
|
162
|
+
return isinstance(handler_type, ast.Name) and handler_type.id in names
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _analysis_checkpoint():
|
|
166
|
+
"""Let input/rendering run between bounded pieces of background analysis."""
|
|
167
|
+
import threading
|
|
168
|
+
if threading.current_thread() is threading.main_thread():
|
|
169
|
+
return
|
|
170
|
+
from meltygui.code.libcst_conversion import _yield_to_ui
|
|
171
|
+
_yield_to_ui() # also guards a non-main GL thread
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _analysis_matches(pattern, text):
|
|
175
|
+
import re
|
|
176
|
+
_analysis_checkpoint()
|
|
177
|
+
for index, match in enumerate(re.finditer(pattern, text)):
|
|
178
|
+
if index and index % 128 == 0:
|
|
179
|
+
_analysis_checkpoint()
|
|
180
|
+
yield match.group(1)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _parse_for_analysis(text):
|
|
184
|
+
_analysis_checkpoint()
|
|
185
|
+
result = ast.parse(text)
|
|
186
|
+
_analysis_checkpoint()
|
|
187
|
+
return result
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class _Collector:
|
|
191
|
+
"""One pass over the tree building the scope graph + the check worklists."""
|
|
192
|
+
|
|
193
|
+
def __init__(self):
|
|
194
|
+
self._visited = 0
|
|
195
|
+
self.module = _Scope("module", None)
|
|
196
|
+
self.scopes = [self.module]
|
|
197
|
+
self.calls = [] # (Call node, scope, type_guarded)
|
|
198
|
+
self.attr_chains = [] # (base Name node, [(attr, node)...], scope, guarded)
|
|
199
|
+
self.declared_attrs = set() # dotted paths the buffer itself makes visible:
|
|
200
|
+
# `import a.b` / `from a.b import c` / `a.b = ...`
|
|
201
|
+
self.star_import = False
|
|
202
|
+
self.star_modules = [] # `from X import *` source module names
|
|
203
|
+
self._name_guard = 0 # >0 inside try guarded by except NameError
|
|
204
|
+
self._type_guard = 0 # >0 inside try guarded by except TypeError/AttributeError
|
|
205
|
+
|
|
206
|
+
def run(self, tree):
|
|
207
|
+
self._body(tree.body, self.module)
|
|
208
|
+
|
|
209
|
+
def __del__(self):
|
|
210
|
+
# The scope graph is cyclic: child.parent points up while
|
|
211
|
+
# parent.classes[name] = (ClassDef, child) points down - and defs /
|
|
212
|
+
# classes / calls hold ast nodes, so every pass left the ENTIRE parsed
|
|
213
|
+
# tree as cyclic garbage that only a full gc pass could reclaim (the
|
|
214
|
+
# gc profile of this session's boot collect: 680k objects, ~all ast.*
|
|
215
|
+
# nodes + their __dict__/body lists). Collectors are function-local
|
|
216
|
+
# everywhere, so cut the up-edges here and the tree frees by refcount
|
|
217
|
+
# the moment the pass returns.
|
|
218
|
+
for s in self.scopes:
|
|
219
|
+
s.parent = None
|
|
220
|
+
|
|
221
|
+
# ── plumbing ────────────────────────────────────────────────────────────
|
|
222
|
+
def _new_scope(self, kind, parent):
|
|
223
|
+
s = _Scope(kind, parent)
|
|
224
|
+
self.scopes.append(s)
|
|
225
|
+
return s
|
|
226
|
+
|
|
227
|
+
def _body(self, stmts, scope):
|
|
228
|
+
for stmt in stmts:
|
|
229
|
+
self._visit(stmt, scope)
|
|
230
|
+
|
|
231
|
+
def _visit(self, node, scope):
|
|
232
|
+
self._visited += 1
|
|
233
|
+
if self._visited % 128 == 0:
|
|
234
|
+
_analysis_checkpoint()
|
|
235
|
+
meth = getattr(self, "_v_" + type(node).__name__, None)
|
|
236
|
+
if meth is not None:
|
|
237
|
+
meth(node, scope)
|
|
238
|
+
return
|
|
239
|
+
for child in ast.iter_child_nodes(node):
|
|
240
|
+
self._visit(child, scope)
|
|
241
|
+
|
|
242
|
+
# ── scope makers ────────────────────────────────────────────────────────
|
|
243
|
+
def _function(self, node, scope, name=None):
|
|
244
|
+
flavor = _decorator_flavor(getattr(node, "decorator_list", []))
|
|
245
|
+
if name is not None:
|
|
246
|
+
scope.bind(name)
|
|
247
|
+
if flavor is not None and name not in scope.ambiguous:
|
|
248
|
+
scope.defs[name] = (node, flavor)
|
|
249
|
+
for dec in getattr(node, "decorator_list", []):
|
|
250
|
+
self._visit(dec, scope)
|
|
251
|
+
a = node.args
|
|
252
|
+
for default in list(a.defaults) + [d for d in a.kw_defaults if d is not None]:
|
|
253
|
+
self._visit(default, scope)
|
|
254
|
+
# node.returns / arg annotations deliberately not handled (see above).
|
|
255
|
+
child = self._new_scope("function", scope)
|
|
256
|
+
all_args = list(a.posonlyargs) + list(a.args) + list(a.kwonlyargs)
|
|
257
|
+
for arg in all_args:
|
|
258
|
+
child.bind(arg.arg)
|
|
259
|
+
for var in (a.vararg, a.kwarg):
|
|
260
|
+
if var is not None:
|
|
261
|
+
child.bind(var.arg)
|
|
262
|
+
if scope.kind == "class" and flavor == "plain" and all_args:
|
|
263
|
+
child.self_name = all_args[0].arg
|
|
264
|
+
body = node.body if isinstance(node.body, list) else [node.body]
|
|
265
|
+
self._body(body, child)
|
|
266
|
+
|
|
267
|
+
def _v_FunctionDef(self, node, scope):
|
|
268
|
+
self._function(node, scope, name=node.name)
|
|
269
|
+
|
|
270
|
+
def _v_AsyncFunctionDef(self, node, scope):
|
|
271
|
+
self._function(node, scope, name=node.name)
|
|
272
|
+
|
|
273
|
+
def _v_Lambda(self, node, scope):
|
|
274
|
+
self._function(node, scope)
|
|
275
|
+
|
|
276
|
+
def _v_ClassDef(self, node, scope):
|
|
277
|
+
scope.bind(node.name)
|
|
278
|
+
for dec in node.decorator_list:
|
|
279
|
+
self._visit(dec, scope)
|
|
280
|
+
for base in list(node.bases) + list(node.keywords):
|
|
281
|
+
self._visit(base, scope)
|
|
282
|
+
child = self._new_scope("class", scope)
|
|
283
|
+
if not node.decorator_list and node.name not in scope.ambiguous:
|
|
284
|
+
scope.classes[node.name] = (node, child)
|
|
285
|
+
self._body(node.body, child)
|
|
286
|
+
|
|
287
|
+
def _comp(self, node, scope):
|
|
288
|
+
child = self._new_scope("comp", scope)
|
|
289
|
+
first = True
|
|
290
|
+
for gen in node.generators:
|
|
291
|
+
self._visit(gen.iter, scope if first else child)
|
|
292
|
+
first = False
|
|
293
|
+
self._visit(gen.target, child)
|
|
294
|
+
for cond in gen.ifs:
|
|
295
|
+
self._visit(cond, child)
|
|
296
|
+
for field in ("elt", "key", "value"):
|
|
297
|
+
sub = getattr(node, field, None)
|
|
298
|
+
if sub is not None:
|
|
299
|
+
self._visit(sub, child)
|
|
300
|
+
|
|
301
|
+
_v_ListComp = _v_SetComp = _v_GeneratorExp = _v_DictComp = _comp
|
|
302
|
+
|
|
303
|
+
def _try(self, node, scope):
|
|
304
|
+
# Code in a guarded try body is PROBING - that's the handled case, not
|
|
305
|
+
# a bug. except NameError guards the name pass; except TypeError /
|
|
306
|
+
# AttributeError guard the signature / missing-attr pass. The broad
|
|
307
|
+
# handlers guard everything.
|
|
308
|
+
broad = ("Exception", "BaseException")
|
|
309
|
+
name_guarded = any(_handler_catches(h.type, ("NameError",) + broad)
|
|
310
|
+
for h in node.handlers)
|
|
311
|
+
type_guarded = any(_handler_catches(h.type, ("TypeError", "AttributeError") + broad)
|
|
312
|
+
for h in node.handlers)
|
|
313
|
+
self._name_guard += name_guarded
|
|
314
|
+
self._type_guard += type_guarded
|
|
315
|
+
self._body(node.body, scope)
|
|
316
|
+
self._name_guard -= name_guarded
|
|
317
|
+
self._type_guard -= type_guarded
|
|
318
|
+
for h in node.handlers:
|
|
319
|
+
self._visit(h, scope)
|
|
320
|
+
self._body(node.orelse, scope)
|
|
321
|
+
self._body(node.finalbody, scope)
|
|
322
|
+
|
|
323
|
+
_v_Try = _v_TryStar = _try
|
|
324
|
+
|
|
325
|
+
# ── binders and loads ─────────────────────────────────────────────────────
|
|
326
|
+
def _v_Name(self, node, scope):
|
|
327
|
+
if isinstance(node.ctx, ast.Load):
|
|
328
|
+
if not self._name_guard:
|
|
329
|
+
scope.loads.append((node.id, node.lineno))
|
|
330
|
+
else:
|
|
331
|
+
scope.bind(node.id)
|
|
332
|
+
if node.id in scope.global_names:
|
|
333
|
+
self.module.bind(node.id)
|
|
334
|
+
|
|
335
|
+
def _v_Global(self, node, scope):
|
|
336
|
+
scope.global_names.update(node.names)
|
|
337
|
+
for name in node.names:
|
|
338
|
+
# Permissive both ways: resolvable here, and at module level (this
|
|
339
|
+
# function may be the module-level name's only definer).
|
|
340
|
+
scope.bind(name)
|
|
341
|
+
self.module.bind(name)
|
|
342
|
+
|
|
343
|
+
def _v_Nonlocal(self, node, scope):
|
|
344
|
+
# compile() already rejected a nonlocal with no enclosing binding.
|
|
345
|
+
for name in node.names:
|
|
346
|
+
scope.bind(name)
|
|
347
|
+
|
|
348
|
+
def _v_Import(self, node, scope):
|
|
349
|
+
for alias in node.names:
|
|
350
|
+
scope.bind(alias.asname or alias.name.partition(".")[0],
|
|
351
|
+
is_import=True)
|
|
352
|
+
# `import a.b.c` guarantees a.b and a.b.c exist as attributes.
|
|
353
|
+
parts = alias.name.split(".")
|
|
354
|
+
for i in range(1, len(parts) + 1):
|
|
355
|
+
self.declared_attrs.add(".".join(parts[:i]))
|
|
356
|
+
|
|
357
|
+
def _v_ImportFrom(self, node, scope):
|
|
358
|
+
if node.module and not node.level:
|
|
359
|
+
parts = node.module.split(".")
|
|
360
|
+
for i in range(1, len(parts) + 1):
|
|
361
|
+
self.declared_attrs.add(".".join(parts[:i]))
|
|
362
|
+
for alias in node.names:
|
|
363
|
+
if alias.name == "*":
|
|
364
|
+
self.star_import = True
|
|
365
|
+
# Which module the * came from (None for relative imports) -
|
|
366
|
+
# _module_text_binds resolves the export list through the
|
|
367
|
+
# LIVE module so a star import doesn't force the whole
|
|
368
|
+
# binds answer to "unknowable".
|
|
369
|
+
self.star_modules.append(node.module if not node.level else None)
|
|
370
|
+
else:
|
|
371
|
+
scope.bind(alias.asname or alias.name, is_import=True)
|
|
372
|
+
if node.module and not node.level:
|
|
373
|
+
# `from a.b import c` makes c an attribute of a.b too.
|
|
374
|
+
self.declared_attrs.add(f"{node.module}.{alias.name}")
|
|
375
|
+
|
|
376
|
+
def _v_ExceptHandler(self, node, scope):
|
|
377
|
+
if node.name:
|
|
378
|
+
scope.bind(node.name)
|
|
379
|
+
if node.type is not None:
|
|
380
|
+
self._visit(node.type, scope)
|
|
381
|
+
self._body(node.body, scope)
|
|
382
|
+
|
|
383
|
+
def _v_NamedExpr(self, node, scope):
|
|
384
|
+
# A walrus binds in the nearest enclosing non-comprehension scope; bind
|
|
385
|
+
# in the comp scope too so later use inside the same comp resolves.
|
|
386
|
+
target = scope
|
|
387
|
+
while target.kind == "comp":
|
|
388
|
+
target = target.parent
|
|
389
|
+
target.bind(node.target.id)
|
|
390
|
+
if scope is not target:
|
|
391
|
+
scope.bind(node.target.id)
|
|
392
|
+
self._visit(node.value, scope)
|
|
393
|
+
|
|
394
|
+
def _v_AnnAssign(self, node, scope):
|
|
395
|
+
self._visit(node.target, scope) # binds (Store ctx)
|
|
396
|
+
if node.value is not None:
|
|
397
|
+
self._visit(node.value, scope)
|
|
398
|
+
# node.annotation deliberately ignored.
|
|
399
|
+
|
|
400
|
+
def _v_MatchAs(self, node, scope):
|
|
401
|
+
if node.name:
|
|
402
|
+
scope.bind(node.name)
|
|
403
|
+
if node.pattern is not None:
|
|
404
|
+
self._visit(node.pattern, scope)
|
|
405
|
+
|
|
406
|
+
def _v_MatchStar(self, node, scope):
|
|
407
|
+
if node.name:
|
|
408
|
+
scope.bind(node.name)
|
|
409
|
+
|
|
410
|
+
def _v_MatchMapping(self, node, scope):
|
|
411
|
+
if node.rest:
|
|
412
|
+
scope.bind(node.rest)
|
|
413
|
+
for child in ast.iter_child_nodes(node):
|
|
414
|
+
self._visit(child, scope)
|
|
415
|
+
|
|
416
|
+
def _v_TypeAlias(self, node, scope): # py3.12 `type X = ...` - annotation-like
|
|
417
|
+
if isinstance(node.name, ast.Name):
|
|
418
|
+
scope.bind(node.name.id)
|
|
419
|
+
|
|
420
|
+
def _v_Call(self, node, scope):
|
|
421
|
+
self.calls.append((node, scope, self._type_guard > 0))
|
|
422
|
+
for child in ast.iter_child_nodes(node):
|
|
423
|
+
self._visit(child, scope)
|
|
424
|
+
|
|
425
|
+
def _v_Attribute(self, node, scope):
|
|
426
|
+
if isinstance(node.ctx, (ast.Store, ast.Del)):
|
|
427
|
+
# `obj.flag = True` makes the attr exist at runtime - declare the
|
|
428
|
+
# whole dotted path so a later LOAD of it doesn't flag as missing.
|
|
429
|
+
base, attrs = _unwind_chain(node.value)
|
|
430
|
+
if base is not None:
|
|
431
|
+
self.declared_attrs.add(
|
|
432
|
+
".".join([base.id] + [a for a, _ in attrs] + [node.attr]))
|
|
433
|
+
self._visit(node.value, scope)
|
|
434
|
+
return
|
|
435
|
+
base, attrs = _unwind_chain(node)
|
|
436
|
+
if base is not None:
|
|
437
|
+
self.attr_chains.append(
|
|
438
|
+
(base, attrs, scope, self._name_guard > 0 or self._type_guard > 0))
|
|
439
|
+
# We don't recurse into the chain, so record the base node here.
|
|
440
|
+
if not self._name_guard:
|
|
441
|
+
scope.loads.append((base.id, base.lineno))
|
|
442
|
+
return
|
|
443
|
+
for child in ast.iter_child_nodes(node):
|
|
444
|
+
self._visit(child, scope)
|
|
445
|
+
|
|
446
|
+
def _v_Compare(self, node, scope):
|
|
447
|
+
# `'name' in globals()` is a deliberate is-it-re-bound-yet guard
|
|
448
|
+
# (latent_descent's server hooks). Treat the tested name as module-bound
|
|
449
|
+
# so the guarded load it gates doesn't flag.
|
|
450
|
+
if (len(node.ops) == 1 and isinstance(node.ops[0], ast.In)
|
|
451
|
+
and isinstance(node.left, ast.Constant)
|
|
452
|
+
and isinstance(node.left.value, str)
|
|
453
|
+
and node.left.value.isidentifier()
|
|
454
|
+
and isinstance(node.comparators[0], ast.Call)
|
|
455
|
+
and isinstance(node.comparators[0].func, ast.Name)
|
|
456
|
+
and node.comparators[0].func.id in ("globals", "locals", "vars", "dir")):
|
|
457
|
+
self.module.bind(node.left.value)
|
|
458
|
+
for child in ast.iter_child_nodes(node):
|
|
459
|
+
self._visit(child, scope)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
# ── name resolution ──────────────────────────────────────────────────────────
|
|
463
|
+
|
|
464
|
+
def _resolves(scope, name):
|
|
465
|
+
"""Python's lookup rule: own scope, then enclosing scopes SKIPPING class
|
|
466
|
+
scopes (a class body is invisible to the functions nested inside it)."""
|
|
467
|
+
own = True
|
|
468
|
+
s = scope
|
|
469
|
+
while s is not None:
|
|
470
|
+
if (own or s.kind != "class") and name in s.binds:
|
|
471
|
+
return True
|
|
472
|
+
own = False
|
|
473
|
+
s = s.parent
|
|
474
|
+
return False
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _binding_scope(scope, name):
|
|
478
|
+
"""The scope whose binding a load of `name` would see, or None."""
|
|
479
|
+
own = True
|
|
480
|
+
s = scope
|
|
481
|
+
while s is not None:
|
|
482
|
+
if (own or s.kind != "class") and name in s.binds:
|
|
483
|
+
return s
|
|
484
|
+
own = False
|
|
485
|
+
s = s.parent
|
|
486
|
+
return None
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _lookup_def(scope, name):
|
|
490
|
+
"""The def/class a bare-name call statically pins to, or None. The FIRST
|
|
491
|
+
scope binding the name decides (a nearer non-def binding shadows an outer
|
|
492
|
+
def → unknowable → None). Class-scope defs are skipped except from the
|
|
493
|
+
class body itself — a bare-name method call is an unbound call whose
|
|
494
|
+
`self` convention we can't assume."""
|
|
495
|
+
s = _binding_scope(scope, name)
|
|
496
|
+
if s is None or name in s.ambiguous:
|
|
497
|
+
return None
|
|
498
|
+
if name in s.defs and s.kind != "class":
|
|
499
|
+
return ("func", *s.defs[name])
|
|
500
|
+
if name in s.classes:
|
|
501
|
+
return ("cls", *s.classes[name])
|
|
502
|
+
return None
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _enclosing_method_class(scope, base_name):
|
|
506
|
+
"""For a `self.m(...)` call: the class scope owning the method we're inside,
|
|
507
|
+
if `base_name` is that method's first arg. None otherwise."""
|
|
508
|
+
s = scope
|
|
509
|
+
while s is not None:
|
|
510
|
+
if s.kind == "function" and s.self_name == base_name:
|
|
511
|
+
parent = s.parent
|
|
512
|
+
return parent if parent is not None and parent.kind == "class" else None
|
|
513
|
+
if s.kind == "function" and s.self_name is None:
|
|
514
|
+
return None # an inner plain def shadows the method's self
|
|
515
|
+
s = s.parent
|
|
516
|
+
return None
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
# ── call-signature matching ──────────────────────────────────────────────
|
|
520
|
+
|
|
521
|
+
class _Spec:
|
|
522
|
+
"""One callable's parameters, however we learned them (buffer ast,
|
|
523
|
+
inspect.signature, or a Cython embedsignature doc line)."""
|
|
524
|
+
|
|
525
|
+
def __init__(self):
|
|
526
|
+
self.named = [] # positional(-or-keyword) names, in order
|
|
527
|
+
self.n_posonly = 0 # leading slice of `named` not passable by kw
|
|
528
|
+
self.required = set() # names with no default (positional + kwonly)
|
|
529
|
+
self.kwonly = []
|
|
530
|
+
self.kw_required = set()
|
|
531
|
+
self.has_var_pos = False
|
|
532
|
+
self.has_var_kw = False
|
|
533
|
+
self.types = {} # name -> 'float'|'int'|'str'|'bool', only
|
|
534
|
+
# where known (doc C type / annotation)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _spec_from_arguments(a, skip_first=0):
|
|
538
|
+
spec = _Spec()
|
|
539
|
+
named = [x.arg for x in a.posonlyargs] + [x.arg for x in a.args]
|
|
540
|
+
n_posonly = len(a.posonlyargs)
|
|
541
|
+
if skip_first:
|
|
542
|
+
if not named:
|
|
543
|
+
return None # *args-only def used as a method - fine
|
|
544
|
+
named = named[1:]
|
|
545
|
+
n_posonly = max(0, n_posonly - 1)
|
|
546
|
+
spec.named = named
|
|
547
|
+
spec.n_posonly = n_posonly
|
|
548
|
+
spec.required = set(named[:len(named) - len(a.defaults)])
|
|
549
|
+
spec.kwonly = [x.arg for x in a.kwonlyargs]
|
|
550
|
+
spec.kw_required = {x.arg for x, d in zip(a.kwonlyargs, a.kw_defaults) if d is None}
|
|
551
|
+
spec.required |= spec.kw_required
|
|
552
|
+
spec.has_var_pos = a.vararg is not None
|
|
553
|
+
spec.has_var_kw = a.kwarg is not None
|
|
554
|
+
for arg in list(a.posonlyargs) + list(a.args) + list(a.kwonlyargs):
|
|
555
|
+
ann = arg.annotation
|
|
556
|
+
if isinstance(ann, ast.Name) and ann.id in _CHECKED_TYPES:
|
|
557
|
+
spec.types[arg.arg] = ann.id
|
|
558
|
+
return spec
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _spec_from_signature(sig):
|
|
562
|
+
P = inspect.Parameter
|
|
563
|
+
spec = _Spec()
|
|
564
|
+
for p in sig.parameters.values():
|
|
565
|
+
ann = p.annotation
|
|
566
|
+
if isinstance(ann, type) and ann.__name__ in _CHECKED_TYPES:
|
|
567
|
+
spec.types[p.name] = ann.__name__
|
|
568
|
+
elif isinstance(ann, str) and ann in _CHECKED_TYPES:
|
|
569
|
+
spec.types[p.name] = ann
|
|
570
|
+
if p.kind in (P.POSITIONAL_ONLY, P.POSITIONAL_OR_KEYWORD):
|
|
571
|
+
spec.named.append(p.name)
|
|
572
|
+
if p.kind == P.POSITIONAL_ONLY:
|
|
573
|
+
spec.n_posonly = len(spec.named)
|
|
574
|
+
if p.default is P.empty:
|
|
575
|
+
spec.required.add(p.name)
|
|
576
|
+
elif p.kind == P.KEYWORD_ONLY:
|
|
577
|
+
spec.kwonly.append(p.name)
|
|
578
|
+
if p.default is P.empty:
|
|
579
|
+
spec.kw_required.add(p.name)
|
|
580
|
+
spec.required.add(p.name)
|
|
581
|
+
elif p.kind == P.VAR_POSITIONAL:
|
|
582
|
+
spec.has_var_pos = True
|
|
583
|
+
elif p.kind == P.VAR_KEYWORD:
|
|
584
|
+
spec.has_var_kw = True
|
|
585
|
+
return spec
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _split_top_level(text):
|
|
589
|
+
parts, depth, start = [], 0, 0
|
|
590
|
+
for i, ch in enumerate(text):
|
|
591
|
+
if ch in "([{":
|
|
592
|
+
depth += 1
|
|
593
|
+
elif ch in ")]}":
|
|
594
|
+
depth -= 1
|
|
595
|
+
elif ch == "," and depth == 0:
|
|
596
|
+
parts.append(text[start:i])
|
|
597
|
+
start = i + 1
|
|
598
|
+
parts.append(text[start:])
|
|
599
|
+
return parts
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _spec_from_doc(fname, doc):
|
|
603
|
+
"""Cython embedsignature fallback: pyimgui-style C functions raise on
|
|
604
|
+
inspect.signature but lead their docstring with `dummy(width, height)` /
|
|
605
|
+
`arrow_button(str label, ImGuiDir direction=DIRECTION_NONE)`. Parse that
|
|
606
|
+
line into a spec; param tokens may carry a C type prefix (drop it). Anything
|
|
607
|
+
that doesn't look exactly like a signature line → None (no check)."""
|
|
608
|
+
if not doc:
|
|
609
|
+
return None
|
|
610
|
+
lines = doc.strip().split("\n")
|
|
611
|
+
line = lines[0].strip()
|
|
612
|
+
# The doc line may sign itself with an ALIAS TARGET's name - pyimgui's
|
|
613
|
+
# set_cursor_position shares set_cursor_pos's doc ("set_cursor_pos(
|
|
614
|
+
# local_position)") - so accept any identifier-headed signature line and use
|
|
615
|
+
# ITS name for the overload scan; `fname` stays the alias's spelling for
|
|
616
|
+
# messages. Prose first lines fail the identifier/paren test here or the
|
|
617
|
+
# strict per-piece validation below.
|
|
618
|
+
docname = line.partition("(")[0].strip()
|
|
619
|
+
if not docname.isidentifier() or not line.startswith(docname + "("):
|
|
620
|
+
return None
|
|
621
|
+
# Polymorphic callables document each overload on its own line - bare
|
|
622
|
+
# ("slice(stop)" / "slice(start, stop[, step])") or directive-marked the way
|
|
623
|
+
# torch does (".. function:: mean(input, dim, ...)"). The first line alone
|
|
624
|
+
# is a lie, so any bare overload line disqualifies the whole fallback.
|
|
625
|
+
for more in lines[1:]:
|
|
626
|
+
more = more.strip()
|
|
627
|
+
for marker in (".. function::", ".. method::"):
|
|
628
|
+
if more.startswith(marker):
|
|
629
|
+
more = more[len(marker):].strip()
|
|
630
|
+
if more.startswith(docname + "("):
|
|
631
|
+
return None
|
|
632
|
+
# Take exactly the BALANCED paren group after the name - torch-style lines
|
|
633
|
+
# carry a return annotation after it ("sort(input, ...) -> (Tensor,
|
|
634
|
+
# LongTensor)") that must not leak into the spec.
|
|
635
|
+
head = line[len(docname):]
|
|
636
|
+
inner = tail = None
|
|
637
|
+
depth = 0
|
|
638
|
+
for i, ch in enumerate(head):
|
|
639
|
+
if ch in "([{":
|
|
640
|
+
depth += 1
|
|
641
|
+
elif ch in ")]}":
|
|
642
|
+
depth -= 1
|
|
643
|
+
if depth == 0:
|
|
644
|
+
inner = head[1:i].strip()
|
|
645
|
+
tail = head[i + 1:].strip()
|
|
646
|
+
break
|
|
647
|
+
if inner is None or (tail and not tail.startswith("->")):
|
|
648
|
+
return None
|
|
649
|
+
spec = _Spec()
|
|
650
|
+
if not inner:
|
|
651
|
+
return spec
|
|
652
|
+
kwonly = False
|
|
653
|
+
for piece in _split_top_level(inner):
|
|
654
|
+
piece = piece.strip()
|
|
655
|
+
if not piece or piece == "...":
|
|
656
|
+
return None # can't check a partial signature
|
|
657
|
+
if piece == "/":
|
|
658
|
+
spec.n_posonly = len(spec.named)
|
|
659
|
+
continue
|
|
660
|
+
if piece == "*":
|
|
661
|
+
kwonly = True
|
|
662
|
+
continue
|
|
663
|
+
if piece.startswith("**"):
|
|
664
|
+
spec.has_var_kw = True
|
|
665
|
+
continue
|
|
666
|
+
if piece.startswith("*"):
|
|
667
|
+
# Native docstrings use variadic notation for overload families,
|
|
668
|
+
# not necessarily Python binding rules: torch.rand(*size) also
|
|
669
|
+
# accepts size=(...). Without a real signature we cannot safely
|
|
670
|
+
# reject keywords or infer collisions/required arguments.
|
|
671
|
+
return None
|
|
672
|
+
name_part = piece.split("=", 1)[0].strip()
|
|
673
|
+
tokens = name_part.split()
|
|
674
|
+
pname = tokens[-1] if tokens else ""
|
|
675
|
+
if not pname.isidentifier():
|
|
676
|
+
return None
|
|
677
|
+
if len(tokens) >= 2:
|
|
678
|
+
# C type prefix ("float position") - convert the ones the literal
|
|
679
|
+
# type check knows; unknown types simply aren't checked.
|
|
680
|
+
t = _DOC_TYPE_MAP.get(tokens[-2].lstrip("*"))
|
|
681
|
+
if t is not None:
|
|
682
|
+
spec.types[pname] = t
|
|
683
|
+
has_default = "=" in piece
|
|
684
|
+
if kwonly:
|
|
685
|
+
spec.kwonly.append(pname)
|
|
686
|
+
if not has_default:
|
|
687
|
+
spec.kw_required.add(pname)
|
|
688
|
+
spec.required.add(pname)
|
|
689
|
+
else:
|
|
690
|
+
# A required positional AFTER a default one is illegal in real
|
|
691
|
+
# Python - the doc line is describing overload shorthand (torch's
|
|
692
|
+
# "arange(start=0, end, step=1)"), not a signature. Trust nothing.
|
|
693
|
+
if not has_default and any(n not in spec.required for n in spec.named):
|
|
694
|
+
return None
|
|
695
|
+
spec.named.append(pname)
|
|
696
|
+
if not has_default:
|
|
697
|
+
spec.required.add(pname)
|
|
698
|
+
return spec
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def _match_spec(fname, spec, call):
|
|
702
|
+
"""One mismatch message, or None. Mirrors CPython's binding rules but
|
|
703
|
+
checks only what the call makes knowable: *args in the call hides
|
|
704
|
+
positional counts, ** hides keyword coverage."""
|
|
705
|
+
# A *splat normally makes the positional count unknowable - EXCEPT a
|
|
706
|
+
# literal tuple/list (`f(*(0, 0))`), whose count is right there.
|
|
707
|
+
star_args = False
|
|
708
|
+
npos = 0
|
|
709
|
+
for x in call.args:
|
|
710
|
+
if isinstance(x, ast.Starred):
|
|
711
|
+
v = x.value
|
|
712
|
+
if (isinstance(v, (ast.Tuple, ast.List))
|
|
713
|
+
and not any(isinstance(e, ast.Starred) for e in v.elts)):
|
|
714
|
+
npos += len(v.elts)
|
|
715
|
+
else:
|
|
716
|
+
star_args = True
|
|
717
|
+
else:
|
|
718
|
+
npos += 1
|
|
719
|
+
kw_expand = any(k.arg is None for k in call.keywords)
|
|
720
|
+
kw_names = [k.arg for k in call.keywords if k.arg is not None]
|
|
721
|
+
|
|
722
|
+
if not spec.has_var_kw:
|
|
723
|
+
allowed = set(spec.named[spec.n_posonly:]) | set(spec.kwonly)
|
|
724
|
+
for k in kw_names:
|
|
725
|
+
if k not in allowed:
|
|
726
|
+
return f"{fname}() got an unexpected keyword argument '{k}'"
|
|
727
|
+
|
|
728
|
+
if star_args:
|
|
729
|
+
return None # positional count unknowable from here on
|
|
730
|
+
|
|
731
|
+
if npos > len(spec.named) and not spec.has_var_pos:
|
|
732
|
+
plural = "s" if len(spec.named) != 1 else ""
|
|
733
|
+
return (f"{fname}() takes {len(spec.named)} positional argument{plural} "
|
|
734
|
+
f"but {npos} were given")
|
|
735
|
+
|
|
736
|
+
consumed = set(spec.named[:npos])
|
|
737
|
+
for k in kw_names:
|
|
738
|
+
if k in consumed:
|
|
739
|
+
return f"{fname}() got multiple values for argument '{k}'"
|
|
740
|
+
|
|
741
|
+
if not kw_expand:
|
|
742
|
+
kw_set = set(kw_names)
|
|
743
|
+
missing = [p for i, p in enumerate(spec.named)
|
|
744
|
+
if i >= npos and p in spec.required and p not in kw_set]
|
|
745
|
+
missing += [k for k in spec.kwonly if k in spec.kw_required and k not in kw_set]
|
|
746
|
+
if missing:
|
|
747
|
+
listed = ", ".join(f"'{m}'" for m in missing)
|
|
748
|
+
plural = "s" if len(missing) != 1 else ""
|
|
749
|
+
return f"{fname}() missing required argument{plural}: {listed}"
|
|
750
|
+
return _literal_type_mismatch(fname, spec, call)
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
# Declared param types the literal check knows, and the literal types each
|
|
754
|
+
# accepts. Deliberately narrower than Python's coercion rules: bool→float is
|
|
755
|
+
# LEGAL at runtime (bool is an int) but `same_line(False)` is a bug every
|
|
756
|
+
# time it's written, so numeric params reject bool literals. None literals
|
|
757
|
+
# are never checked (Optional params are indistinguishable statically).
|
|
758
|
+
_CHECKED_TYPES = frozenset({"float", "int", "str", "bool"})
|
|
759
|
+
_TYPE_ACCEPTS = {
|
|
760
|
+
"float": ("float", "int"),
|
|
761
|
+
"int": ("int",),
|
|
762
|
+
"str": ("str",),
|
|
763
|
+
"bool": ("bool",),
|
|
764
|
+
}
|
|
765
|
+
# Doc C-type spellings → the checked type they mean; anything else unchecked.
|
|
766
|
+
_DOC_TYPE_MAP = {
|
|
767
|
+
"float": "float", "double": "float",
|
|
768
|
+
"int": "int", "long": "int", "short": "int", "unsigned": "int",
|
|
769
|
+
"size_t": "int", "Py_ssize_t": "int",
|
|
770
|
+
"bool": "bool", "bint": "bool",
|
|
771
|
+
"str": "str", "string": "str",
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
def _literal_arg_type(node):
|
|
776
|
+
"""'bool'/'int'/'float'/'str' when the argument is that LITERAL (unary
|
|
777
|
+
+/- kept for numbers), else None — expressions, names, calls and None
|
|
778
|
+
literals are never type-checked."""
|
|
779
|
+
if isinstance(node, ast.Constant):
|
|
780
|
+
v = node.value
|
|
781
|
+
if v is True or v is False:
|
|
782
|
+
return "bool" # before int - bool subclasses int
|
|
783
|
+
if isinstance(v, float):
|
|
784
|
+
return "float"
|
|
785
|
+
if isinstance(v, int):
|
|
786
|
+
return "int"
|
|
787
|
+
if isinstance(v, str):
|
|
788
|
+
return "str"
|
|
789
|
+
return None
|
|
790
|
+
if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.USub, ast.UAdd)):
|
|
791
|
+
t = _literal_arg_type(node.operand)
|
|
792
|
+
return t if t in ("int", "float") else None
|
|
793
|
+
return None
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def _literal_type_mismatch(fname, spec, call):
|
|
797
|
+
"""A wrong-TYPE message for a LITERAL argument against a DECLARED param
|
|
798
|
+
type (doc C type or float/int/str/bool annotation), or None. Runs last in
|
|
799
|
+
_match_spec, so the call already binds; only (declared, literal) pairs
|
|
800
|
+
the tables above know are judged — everything else is silence."""
|
|
801
|
+
if not spec.types:
|
|
802
|
+
return None
|
|
803
|
+
try:
|
|
804
|
+
from meltygui.core.runtime.toggles import Toggles
|
|
805
|
+
if not Toggles.TextEditor.lint_literal_types:
|
|
806
|
+
return None
|
|
807
|
+
except Exception:
|
|
808
|
+
pass
|
|
809
|
+
flat = [] # positional args incl. any splats
|
|
810
|
+
for x in call.args:
|
|
811
|
+
if isinstance(x, ast.Starred):
|
|
812
|
+
flat.extend(x.value.elts) # unknowable splats bailed earlier
|
|
813
|
+
else:
|
|
814
|
+
flat.append(x)
|
|
815
|
+
pairs = list(zip(spec.named, flat))
|
|
816
|
+
pairs += [(k.arg, k.value) for k in call.keywords
|
|
817
|
+
if k.arg is not None and k.arg in spec.types]
|
|
818
|
+
for pname, node in pairs:
|
|
819
|
+
want = spec.types.get(pname)
|
|
820
|
+
if want is None:
|
|
821
|
+
continue
|
|
822
|
+
got = _literal_arg_type(node)
|
|
823
|
+
if got is None or got in _TYPE_ACCEPTS[want]:
|
|
824
|
+
continue
|
|
825
|
+
if want == "int" and got == "float":
|
|
826
|
+
# An INTEGRAL float literal (drag_int(min_value=0.0)) coerces
|
|
827
|
+
# losslessly and is common working code - only a fractional
|
|
828
|
+
# literal (2.5) is provably wrong.
|
|
829
|
+
try:
|
|
830
|
+
v = ast.literal_eval(node)
|
|
831
|
+
except Exception:
|
|
832
|
+
continue
|
|
833
|
+
if isinstance(v, float) and v.is_integer():
|
|
834
|
+
continue
|
|
835
|
+
return f"{fname}() expected {want} for '{pname}', got {got}"
|
|
836
|
+
return None
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def _check_call_static(call, scope):
|
|
840
|
+
"""Signature check against defs/classes in the BUFFER. Returns the message
|
|
841
|
+
or None; sets nothing aside — a None just means 'nothing provably wrong'."""
|
|
842
|
+
func = call.func
|
|
843
|
+
if isinstance(func, ast.Name):
|
|
844
|
+
spec_info = _lookup_def(scope, func.id)
|
|
845
|
+
if spec_info is None:
|
|
846
|
+
return None
|
|
847
|
+
if spec_info[0] == "func":
|
|
848
|
+
_, node, flavor = spec_info
|
|
849
|
+
spec = _spec_from_arguments(node.args,
|
|
850
|
+
skip_first=1 if flavor == "class" else 0)
|
|
851
|
+
return _match_spec(func.id, spec, call) if spec else None
|
|
852
|
+
_, node, cls_scope = spec_info
|
|
853
|
+
init = cls_scope.defs.get("__init__")
|
|
854
|
+
if init is not None and init[1] == "plain":
|
|
855
|
+
spec = _spec_from_arguments(init[0].args, skip_first=1)
|
|
856
|
+
return _match_spec(func.id, spec, call) if spec else None
|
|
857
|
+
if (init is None and not node.bases and not node.keywords
|
|
858
|
+
and "__init__" not in cls_scope.binds
|
|
859
|
+
and "__new__" not in cls_scope.binds):
|
|
860
|
+
if call.args or call.keywords:
|
|
861
|
+
return f"{func.id}() takes no arguments"
|
|
862
|
+
return None
|
|
863
|
+
if isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name):
|
|
864
|
+
cls_scope = _enclosing_method_class(scope, func.value.id)
|
|
865
|
+
if cls_scope is None or func.attr in cls_scope.ambiguous:
|
|
866
|
+
return None
|
|
867
|
+
method = cls_scope.defs.get(func.attr)
|
|
868
|
+
if method is None:
|
|
869
|
+
return None # not defined directly - could be inherited
|
|
870
|
+
node, flavor = method
|
|
871
|
+
spec = _spec_from_arguments(node.args,
|
|
872
|
+
skip_first=0 if flavor == "static" else 1)
|
|
873
|
+
return _match_spec(func.attr, spec, call) if spec else None
|
|
874
|
+
return None
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
# ── live-module resolution ───────────────────────────────────────────────────
|
|
878
|
+
|
|
879
|
+
class _LiveCtx:
|
|
880
|
+
|
|
881
|
+
def __init__(self, module, declared):
|
|
882
|
+
try:
|
|
883
|
+
self.ns = dict(vars(module)) if module is not None else None
|
|
884
|
+
except TypeError:
|
|
885
|
+
self.ns = None
|
|
886
|
+
self.declared = declared
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def _module_for(path):
|
|
890
|
+
"""The live module object whose __file__ is `path`, or None — covers
|
|
891
|
+
hotswap/exec-injected globals no static binding accounts for. The same
|
|
892
|
+
file can appear in sys.modules under SEVERAL names (the studio holds both
|
|
893
|
+
`src.lsd...` and `lsd...` aliases, one of them a barely-initialized stub) —
|
|
894
|
+
take the match with the richest namespace, that's the one that actually
|
|
895
|
+
ran."""
|
|
896
|
+
if not path:
|
|
897
|
+
return None
|
|
898
|
+
target = str(path)
|
|
899
|
+
try:
|
|
900
|
+
target_real = os.path.realpath(target)
|
|
901
|
+
except OSError:
|
|
902
|
+
target_real = target
|
|
903
|
+
best = None
|
|
904
|
+
best_size = -1
|
|
905
|
+
for mod in list(sys.modules.values()):
|
|
906
|
+
f = getattr(mod, "__file__", None)
|
|
907
|
+
if f is not None and (f == target or f == target_real):
|
|
908
|
+
try:
|
|
909
|
+
size = len(vars(mod))
|
|
910
|
+
except TypeError:
|
|
911
|
+
continue
|
|
912
|
+
if size > best_size:
|
|
913
|
+
best, best_size = mod, size
|
|
914
|
+
return best
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def _import_only(scope, name):
|
|
918
|
+
"""True when the binding a load would see comes ONLY from import statements
|
|
919
|
+
— the one case where the live module's value for the name is trustworthy
|
|
920
|
+
(an assignment/def in the buffer may not match the running process yet)."""
|
|
921
|
+
s = _binding_scope(scope, name)
|
|
922
|
+
return (s is not None and name in s.import_binds
|
|
923
|
+
and name not in s.other_binds)
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def _resolve_live_base(ctx, scope, name):
|
|
927
|
+
if ctx.ns is None or not _import_only(scope, name):
|
|
928
|
+
return _MISS
|
|
929
|
+
return ctx.ns.get(name, _MISS)
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
def _walk_chain(ctx, scope, base, attrs):
|
|
933
|
+
"""Resolve base.attr.attr… through LIVE objects. Returns (object, report):
|
|
934
|
+
object is _MISS when unresolvable, report is a (line, msg) missing-attr
|
|
935
|
+
finding. Only ever steps through MODULE objects — instances can hide
|
|
936
|
+
anything behind descriptors/__getattr__, so we stop (silently) at them
|
|
937
|
+
unless they're the final resolved value."""
|
|
938
|
+
obj = _resolve_live_base(ctx, scope, base.id)
|
|
939
|
+
if obj is _MISS:
|
|
940
|
+
return _MISS, None
|
|
941
|
+
dotted = base.id
|
|
942
|
+
for attr, node in attrs:
|
|
943
|
+
if not isinstance(obj, types.ModuleType):
|
|
944
|
+
return _MISS, None
|
|
945
|
+
dotted += "." + attr
|
|
946
|
+
nxt = inspect.getattr_static(obj, attr, _MISS)
|
|
947
|
+
if nxt is _MISS:
|
|
948
|
+
if dotted in ctx.declared or "__getattr__" in vars(obj):
|
|
949
|
+
return _MISS, None # buffer imports/assigns it, or lazy module
|
|
950
|
+
return _MISS, (node.lineno,
|
|
951
|
+
f"module '{obj.__name__}' has no attribute '{attr}'")
|
|
952
|
+
obj = nxt
|
|
953
|
+
return obj, None
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def _is_cython_function(obj):
|
|
957
|
+
"""Cython 3 compiles `def` to its own function type (introspectable, unlike
|
|
958
|
+
Cython 0.29's builtin functions); the embedded doc signature still carries
|
|
959
|
+
the C types."""
|
|
960
|
+
return type(obj).__name__ == "cython_function_or_method"
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
def _live_spec(obj, fname):
|
|
964
|
+
if hasattr(obj, "__wrapped__"):
|
|
965
|
+
return None # the wrapper may inject args (@render_func)
|
|
966
|
+
if _is_cython_function(obj):
|
|
967
|
+
spec = _spec_from_doc(fname, getattr(obj, "__doc__", None))
|
|
968
|
+
if spec is not None:
|
|
969
|
+
return spec
|
|
970
|
+
try:
|
|
971
|
+
return _spec_from_signature(inspect.signature(obj))
|
|
972
|
+
except (ValueError, TypeError):
|
|
973
|
+
# C/Cython callable hiding its signature - pyimgui embeds it in the
|
|
974
|
+
# docstring's first line instead. Real function objects only: a
|
|
975
|
+
# callable INSTANCE hiding its signature (PyOpenGL's glDrawBuffers
|
|
976
|
+
# wrapper) documents C args that parse into the wrong arity.
|
|
977
|
+
if not isinstance(obj, (types.BuiltinFunctionType, types.MethodType)):
|
|
978
|
+
return None
|
|
979
|
+
return _spec_from_doc(fname, getattr(obj, "__doc__", None))
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
def _check_call_live(ctx, call, scope):
|
|
983
|
+
"""Signature check against the LIVE object a call resolves to: a bare
|
|
984
|
+
import-bound name, a builtin, or a dotted module-attribute chain."""
|
|
985
|
+
func = call.func
|
|
986
|
+
obj, fname = _MISS, None
|
|
987
|
+
if isinstance(func, ast.Name):
|
|
988
|
+
fname = func.id
|
|
989
|
+
obj = _resolve_live_base(ctx, scope, fname)
|
|
990
|
+
if obj is _MISS and not _resolves(scope, fname):
|
|
991
|
+
obj = getattr(builtins, fname, _MISS) # len(), isinstance(x), ...
|
|
992
|
+
elif isinstance(func, ast.Attribute):
|
|
993
|
+
base, attrs = _unwind_chain(func)
|
|
994
|
+
if base is not None:
|
|
995
|
+
obj, _ = _walk_chain(ctx, scope, base, attrs)
|
|
996
|
+
fname = attrs[-1][0]
|
|
997
|
+
if obj is _MISS or not callable(obj):
|
|
998
|
+
return None
|
|
999
|
+
spec = _object_spec(obj, fname)
|
|
1000
|
+
return _match_spec(fname, spec, call) if spec is not None else None
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
# ── import suggestions - a SEPARATE channel from errors ─────────────────────
|
|
1004
|
+
#
|
|
1005
|
+
# These are what the editor's quick-fix reads ({line: [statements]},
|
|
1006
|
+
# built by collect_import_suggestions below), never encoded into error
|
|
1007
|
+
# messages: an error describes what's wrong ("expected NAME", "name 'json' is
|
|
1008
|
+
# not defined") whereas suggestions propose a fix, and the two travel side by
|
|
1009
|
+
# side through the chain payload (`lint` vs `imports`).
|
|
1010
|
+
|
|
1011
|
+
# How many candidate statements a symbol gets (the editor shows them in a
|
|
1012
|
+
# dropdown; past a handful they're meaningless, their choice).
|
|
1013
|
+
_MAX_IMPORT_CANDIDATES = 4
|
|
1014
|
+
|
|
1015
|
+
_import_suggestion_cache = {} # name -> [import statements] (possibly empty)
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
def _suggest_import(name):
|
|
1019
|
+
"""The import statements that would bind `name`, best-ranked first — []
|
|
1020
|
+
when nothing importable answers to it (then it's just a typo/unassigned
|
|
1021
|
+
variable).
|
|
1022
|
+
|
|
1023
|
+
Three probes, cheapest first, results cached per name:
|
|
1024
|
+
* `name` is a top-level module already loaded in this process
|
|
1025
|
+
* other LIVE modules bind `name` — to a module (`np` → numpy ⇒
|
|
1026
|
+
`import numpy as np`) or to an object its defining module really
|
|
1027
|
+
exports (`Path` ⇒ `from pathlib import Path`); candidates rank by how
|
|
1028
|
+
many live modules vote for them
|
|
1029
|
+
* `name` is an importable-but-not-yet-loaded module (find_spec — path
|
|
1030
|
+
search only, nothing executes)
|
|
1031
|
+
"""
|
|
1032
|
+
if name in _import_suggestion_cache:
|
|
1033
|
+
return _import_suggestion_cache[name]
|
|
1034
|
+
stmts = []
|
|
1035
|
+
if name in sys.modules and "." not in name:
|
|
1036
|
+
stmts.append(f"import {name}")
|
|
1037
|
+
votes = {}
|
|
1038
|
+
for mod in list(sys.modules.values()):
|
|
1039
|
+
try:
|
|
1040
|
+
obj = vars(mod).get(name, _MISS)
|
|
1041
|
+
except TypeError:
|
|
1042
|
+
continue
|
|
1043
|
+
if obj is _MISS:
|
|
1044
|
+
continue
|
|
1045
|
+
if isinstance(obj, types.ModuleType):
|
|
1046
|
+
top = obj.__name__
|
|
1047
|
+
if top.endswith("." + name) and (stmts
|
|
1048
|
+
or top == f"{getattr(mod, '__name__', '')}.{name}"):
|
|
1049
|
+
# A nested `*.json`-style module, either the binder's own
|
|
1050
|
+
# submodule attribute (datasets.utils.json - not an alias
|
|
1051
|
+
# vote), or trumped by the exact top-level module when one
|
|
1052
|
+
# exists (`import json` beats any wrapper of it).
|
|
1053
|
+
continue
|
|
1054
|
+
cand = (f"import {top}" if top == name
|
|
1055
|
+
else f"import {top} as {name}")
|
|
1056
|
+
else:
|
|
1057
|
+
owner = getattr(obj, "__module__", None)
|
|
1058
|
+
owner_mod = sys.modules.get(owner) if owner else None
|
|
1059
|
+
if (owner_mod is None
|
|
1060
|
+
or getattr(owner_mod, name, _MISS) is not obj):
|
|
1061
|
+
continue # not really importable as `name` from there
|
|
1062
|
+
cand = f"from {owner} import {name}"
|
|
1063
|
+
votes[cand] = votes.get(cand, 0) + 1
|
|
1064
|
+
for cand, _n in sorted(votes.items(), key=lambda kv: -kv[1]):
|
|
1065
|
+
if cand not in stmts:
|
|
1066
|
+
stmts.append(cand)
|
|
1067
|
+
if not stmts:
|
|
1068
|
+
import importlib.util
|
|
1069
|
+
try:
|
|
1070
|
+
if "." not in name and importlib.util.find_spec(name) is not None:
|
|
1071
|
+
stmts.append(f"import {name}")
|
|
1072
|
+
except Exception:
|
|
1073
|
+
pass
|
|
1074
|
+
stmts = stmts[:_MAX_IMPORT_CANDIDATES]
|
|
1075
|
+
_import_suggestion_cache[name] = stmts
|
|
1076
|
+
return stmts
|
|
1077
|
+
|
|
1078
|
+
|
|
1079
|
+
_project_import_suggestion_cache = {}
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
def invalidate_import_bindings(path=None):
|
|
1083
|
+
"""An explicit import edit affects uses in every block, not just its own line."""
|
|
1084
|
+
keys = {None, str(path) if path else None}
|
|
1085
|
+
if path is not None:
|
|
1086
|
+
from pathlib import Path
|
|
1087
|
+
keys.add(str(Path(path).resolve()))
|
|
1088
|
+
for key in keys:
|
|
1089
|
+
_file_binds_cache.pop(key, None)
|
|
1090
|
+
_inc_scan_state.pop(key, None)
|
|
1091
|
+
for span in (False, True):
|
|
1092
|
+
_inc_lint_state.pop((key, span), None)
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
def _suggest_import_for_path(name, path, cached_only=False):
|
|
1096
|
+
if path is None:
|
|
1097
|
+
return _import_suggestion_cache.get(name) if cached_only else _suggest_import(name)
|
|
1098
|
+
key = (str(path), name)
|
|
1099
|
+
if cached_only:
|
|
1100
|
+
return _project_import_suggestion_cache.get(key)
|
|
1101
|
+
from meltygui.core.runtime.extensions import get
|
|
1102
|
+
provider = get('source_imports')
|
|
1103
|
+
statements = provider(name, path) if provider else _suggest_import(name)
|
|
1104
|
+
_project_import_suggestion_cache[key] = statements[:_MAX_IMPORT_CANDIDATES]
|
|
1105
|
+
return _project_import_suggestion_cache[key]
|
|
1106
|
+
|
|
1107
|
+
|
|
1108
|
+
_project_importables_cache = None # (src_module_count, rows, stmts)
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
def project_importables():
|
|
1112
|
+
"""(rows, stmts) for the completion popup's import shortcuts: `rows` is a
|
|
1113
|
+
sorted [(name, "auto_import")] list of the main package's top-level classes
|
|
1114
|
+
and modules, `stmts` maps each name to the import statement that binds it
|
|
1115
|
+
(`DrawState` → `from src.…draw_state import DrawState`, `draw_state` →
|
|
1116
|
+
`from src.…core_model import draw_state`). Only the canonical `src.`
|
|
1117
|
+
module identities are scanned (the bare `lsd.` aliases of the same files
|
|
1118
|
+
would spell imports the project doesn't use). Classes win a name collision
|
|
1119
|
+
with a module. Cached; rebuilt when the number of loaded src. modules
|
|
1120
|
+
changes (imports only ever add modules mid-session)."""
|
|
1121
|
+
global _project_importables_cache
|
|
1122
|
+
from meltygui.code.fileref import is_editable_source
|
|
1123
|
+
src_mods = {n: m for n, m in list(sys.modules.items())
|
|
1124
|
+
if m is not None and getattr(m, "__file__", None)
|
|
1125
|
+
and is_editable_source(m.__file__)}
|
|
1126
|
+
stamp = len(src_mods)
|
|
1127
|
+
if (_project_importables_cache is not None
|
|
1128
|
+
and _project_importables_cache[0] == stamp):
|
|
1129
|
+
return _project_importables_cache[1], _project_importables_cache[2]
|
|
1130
|
+
stmts = {}
|
|
1131
|
+
for mod_name in sorted(src_mods):
|
|
1132
|
+
mod = src_mods[mod_name]
|
|
1133
|
+
try:
|
|
1134
|
+
ns = vars(mod)
|
|
1135
|
+
except TypeError:
|
|
1136
|
+
continue
|
|
1137
|
+
for attr, obj in list(ns.items()):
|
|
1138
|
+
if (not attr.startswith("_") and isinstance(obj, type)
|
|
1139
|
+
and getattr(obj, "__module__", None) == mod_name):
|
|
1140
|
+
stmts.setdefault(attr, f"from {mod_name} import {attr}")
|
|
1141
|
+
for mod_name in sorted(src_mods):
|
|
1142
|
+
parent, _, base = mod_name.rpartition(".")
|
|
1143
|
+
if parent and not base.startswith("_"):
|
|
1144
|
+
stmts.setdefault(base, f"from {parent} import {base}")
|
|
1145
|
+
rows = [(n, "auto_import") for n in sorted(stmts)]
|
|
1146
|
+
_project_importables_cache = (stamp, rows, stmts)
|
|
1147
|
+
return rows, stmts
|
|
1148
|
+
|
|
1149
|
+
|
|
1150
|
+
_file_binds_cache = {} # str(path) -> (mtime_ns, pending_gen, binds, mono_ts)
|
|
1151
|
+
|
|
1152
|
+
# Freshness floor for the binds cache: within this window a cached answer is
|
|
1153
|
+
# served even when (mtime, pending_gen) moved on. pending_gen bumps on EVERY
|
|
1154
|
+
# queued keystroke save (and redundantly during chain_out echo bursts), so
|
|
1155
|
+
# keying on it alone would re-run the whole-file ast parse near-continuously
|
|
1156
|
+
# while typing. Import-block changes are rare and human-paced - a second of
|
|
1157
|
+
# staleness is invisible, the saved parses are not.
|
|
1158
|
+
_FILE_BINDS_MIN_INTERVAL_S = 1.0
|
|
1159
|
+
|
|
1160
|
+
|
|
1161
|
+
@lag_traced("module-binds parse", 30)
|
|
1162
|
+
def _module_text_binds(path):
|
|
1163
|
+
"""Module-scope names the file's CURRENT text binds — pending-save
|
|
1164
|
+
inclusive, so an import removed (or added) in an unsaved edit changes the
|
|
1165
|
+
answer immediately. This, not the live namespace, is the truth for "does
|
|
1166
|
+
the module still import X": the live module keeps a binding forever once
|
|
1167
|
+
an import RAN, so lint suppression keyed on it could never re-flag a
|
|
1168
|
+
removed import.
|
|
1169
|
+
|
|
1170
|
+
None when the text is unreadable/unparseable or holds a star import —
|
|
1171
|
+
callers fall back to the live namespace (err on silence, exactly the old
|
|
1172
|
+
behavior). Cached on (mtime_ns, pending_gen) per CLAUDE.md's no-content-
|
|
1173
|
+
hash rule, with a time floor (_FILE_BINDS_MIN_INTERVAL_S) so gen churn
|
|
1174
|
+
can't re-parse the file continuously; runs on the lint's worker."""
|
|
1175
|
+
try:
|
|
1176
|
+
st = os.stat(path)
|
|
1177
|
+
except (OSError, TypeError, ValueError):
|
|
1178
|
+
return None
|
|
1179
|
+
now = time.monotonic()
|
|
1180
|
+
|
|
1181
|
+
def _fresh(hit):
|
|
1182
|
+
return hit is not None and (
|
|
1183
|
+
(hit[0] == st.st_mtime_ns and hit[1] == gen)
|
|
1184
|
+
or now - hit[3] < _FILE_BINDS_MIN_INTERVAL_S)
|
|
1185
|
+
|
|
1186
|
+
def _reusable(hit, new_text):
|
|
1187
|
+
# Diff-based shortcut (an incremental import-scan fix): a stale-by-gen
|
|
1188
|
+
# hit whose cached TEXT differs from the new pending text only
|
|
1189
|
+
# inside def/class bodies can't change its MODULE-scope binds -
|
|
1190
|
+
# skip the O(file) ast parse (~60ms on a 340KB file, observed on the
|
|
1191
|
+
# render thread) and keep the set. Text rides in the cache entry
|
|
1192
|
+
# (index 4; older 4-tuples predate this and never reuse).
|
|
1193
|
+
return (hit is not None and len(hit) > 4 and hit[2] is not None
|
|
1194
|
+
and hit[4] is not None and new_text is not None
|
|
1195
|
+
and _binds_unchanged(hit[4], new_text))
|
|
1196
|
+
|
|
1197
|
+
gen = 0
|
|
1198
|
+
text = None
|
|
1199
|
+
key = str(path)
|
|
1200
|
+
try:
|
|
1201
|
+
from meltygui.editor.pending_save import PendingSave
|
|
1202
|
+
from pathlib import Path as _P
|
|
1203
|
+
rp = _P(path).resolve()
|
|
1204
|
+
key = str(rp)
|
|
1205
|
+
gen = PendingSave.pending_gen_for(rp)
|
|
1206
|
+
hit = _file_binds_cache.get(key)
|
|
1207
|
+
if _fresh(hit):
|
|
1208
|
+
return hit[2]
|
|
1209
|
+
text = PendingSave.current_file_text(rp)
|
|
1210
|
+
if _reusable(hit, text):
|
|
1211
|
+
_file_binds_cache[key] = (st.st_mtime_ns, gen, hit[2], now, text)
|
|
1212
|
+
return hit[2]
|
|
1213
|
+
except Exception:
|
|
1214
|
+
hit = _file_binds_cache.get(key)
|
|
1215
|
+
if _fresh(hit):
|
|
1216
|
+
return hit[2]
|
|
1217
|
+
try:
|
|
1218
|
+
with open(path, encoding="utf-8", errors="replace") as f:
|
|
1219
|
+
text = f.read()
|
|
1220
|
+
except OSError:
|
|
1221
|
+
text = None
|
|
1222
|
+
if _reusable(hit, text):
|
|
1223
|
+
_file_binds_cache[key] = (st.st_mtime_ns, gen, hit[2], now, text)
|
|
1224
|
+
return hit[2]
|
|
1225
|
+
binds = None
|
|
1226
|
+
if text is not None:
|
|
1227
|
+
try:
|
|
1228
|
+
file_col = _Collector()
|
|
1229
|
+
file_col.run(_parse_for_analysis(text))
|
|
1230
|
+
binds = set(file_col.module.binds)
|
|
1231
|
+
# Star imports don't make the answer unknowable: resolve each
|
|
1232
|
+
# source to its LIVE module's export list (__all__, else
|
|
1233
|
+
# public names). Only an unloaded/relative star source degrades
|
|
1234
|
+
# to None (fall back to live-ns suppression).
|
|
1235
|
+
for sm in file_col.star_modules:
|
|
1236
|
+
mod = sys.modules.get(sm) if sm else None
|
|
1237
|
+
if mod is None:
|
|
1238
|
+
binds = None
|
|
1239
|
+
break
|
|
1240
|
+
names = getattr(mod, "__all__", None)
|
|
1241
|
+
if names is None:
|
|
1242
|
+
names = [n for n in vars(mod) if not n.startswith("_")]
|
|
1243
|
+
binds.update(names)
|
|
1244
|
+
if binds is not None:
|
|
1245
|
+
binds = frozenset(binds)
|
|
1246
|
+
except (SyntaxError, ValueError, RecursionError, TypeError):
|
|
1247
|
+
binds = None
|
|
1248
|
+
_file_binds_cache[key] = (st.st_mtime_ns, gen, binds, now, text)
|
|
1249
|
+
return binds
|
|
1250
|
+
|
|
1251
|
+
|
|
1252
|
+
def _binds_unchanged(old_text, new_text):
|
|
1253
|
+
"""True when the old→new edit provably can't change MODULE-scope binds:
|
|
1254
|
+
every changed line (both sides of the diff) is blank/comment or indented,
|
|
1255
|
+
contains no import/global statement, and the enclosing column-0 block is
|
|
1256
|
+
a def/class/decorator — whose interior binds function or class scope, not
|
|
1257
|
+
module scope. An edit inside a module-level `if:`/`try:` block (indented
|
|
1258
|
+
yet module-scope) fails the header check and re-parses. Conservative by
|
|
1259
|
+
construction: any doubt → False → the full parse runs."""
|
|
1260
|
+
if old_text is new_text or old_text == new_text:
|
|
1261
|
+
return True
|
|
1262
|
+
a = old_text.split("\n")
|
|
1263
|
+
b = new_text.split("\n")
|
|
1264
|
+
na, nb = len(a), len(b)
|
|
1265
|
+
pre = 0
|
|
1266
|
+
m = min(na, nb)
|
|
1267
|
+
while pre < m and a[pre] == b[pre]:
|
|
1268
|
+
pre += 1
|
|
1269
|
+
suf = 0
|
|
1270
|
+
while suf < (na - pre) and suf < (nb - pre) and a[na - 1 - suf] == b[nb - 1 - suf]:
|
|
1271
|
+
suf += 1
|
|
1272
|
+
lo, hi = pre, nb - suf
|
|
1273
|
+
for ln in b[lo:hi] + a[lo:na - suf]:
|
|
1274
|
+
s = ln.lstrip()
|
|
1275
|
+
if not s or s.startswith("#"):
|
|
1276
|
+
continue
|
|
1277
|
+
if ln[0] not in " \t":
|
|
1278
|
+
return False # a top-level line changed
|
|
1279
|
+
if s.startswith(("global ", "import ", "from ")):
|
|
1280
|
+
return False
|
|
1281
|
+
start = min(lo, nb - 1)
|
|
1282
|
+
while start > 0 and (not b[start] or b[start][0] in " \t"):
|
|
1283
|
+
start -= 1
|
|
1284
|
+
head = b[start].lstrip() if 0 <= start < nb else ""
|
|
1285
|
+
return head.startswith(("def ", "async def ", "class ", "@"))
|
|
1286
|
+
|
|
1287
|
+
|
|
1288
|
+
# ── pending-truth signature source ───────────────────────────────────────────
|
|
1289
|
+
#
|
|
1290
|
+
# The live object's inspect.signature reflects the last COMPILE, and disk only
|
|
1291
|
+
# updates at shutdown (PendingSave defers all writes) - so between an edit and
|
|
1292
|
+
# its recompile both lie about a function's parameters. The file's CURRENT
|
|
1293
|
+
# text (disk + every queued unsaved edit, via PendingSave.current_file_text)
|
|
1294
|
+
# is truth reliable, exactly the way Ctrl+B's find usages and the jedi passes
|
|
1295
|
+
# already read it. _signature_table parses that text for call specs; callers
|
|
1296
|
+
# prefer it over live introspection whenever the defining file has pending
|
|
1297
|
+
# edits.
|
|
1298
|
+
|
|
1299
|
+
_file_sig_cache = {} # str(realpath) -> (mtime_ns, pending_gen, table, mono_ts)
|
|
1300
|
+
|
|
1301
|
+
# Sentinel spec: the name IS bound at module scope but its signature is
|
|
1302
|
+
# unknowable (decorated def, rebound name, inherited __init__) - suppresses
|
|
1303
|
+
# both the check and any live-object fallback (err on silence).
|
|
1304
|
+
_SIG_UNKNOWN = object()
|
|
1305
|
+
|
|
1306
|
+
|
|
1307
|
+
def _class_call_spec(node, cls_scope):
|
|
1308
|
+
"""The spec calling class `node` checks against — mirrors the buffer-static
|
|
1309
|
+
rule in _check_call_static: a plain __init__ decides; a bare class with no
|
|
1310
|
+
bases/keywords and no __init__/__new__ takes no args; anything else is
|
|
1311
|
+
unknowable."""
|
|
1312
|
+
init = cls_scope.defs.get("__init__")
|
|
1313
|
+
if init is not None and init[1] == "plain":
|
|
1314
|
+
spec = _spec_from_arguments(init[0].args, skip_first=1)
|
|
1315
|
+
return spec if spec is not None else _SIG_UNKNOWN
|
|
1316
|
+
if (init is None and not node.bases and not node.keywords
|
|
1317
|
+
and "__init__" not in cls_scope.binds
|
|
1318
|
+
and "__new__" not in cls_scope.binds):
|
|
1319
|
+
return _Spec() # no-arg constructor
|
|
1320
|
+
return _SIG_UNKNOWN
|
|
1321
|
+
|
|
1322
|
+
|
|
1323
|
+
def _wrapper_spec_for(decorator_list):
|
|
1324
|
+
"""The calling-convention spec for a render-wrapper-decorated def, or None
|
|
1325
|
+
(unknowable). Applies when every decorator is a known project convention
|
|
1326
|
+
and at least one is a wrapper (@render_func): the live callable is
|
|
1327
|
+
core_render's `wrapper(input_value=None, **kwargs)`, so the ONLY checkable
|
|
1328
|
+
claims are the positional shape (at most one) and an input_value
|
|
1329
|
+
positional/keyword collision — required params may be filled by modes,
|
|
1330
|
+
decorator defaults, comment args or annotation maps, and wrapper-level
|
|
1331
|
+
kwargs (mode, name, ...) are always legal."""
|
|
1332
|
+
names = [_decorator_name(d) for d in decorator_list]
|
|
1333
|
+
known = _TRANSPARENT_DECORATORS | _WRAPPER_DECORATORS
|
|
1334
|
+
if (not names or not all(n in known for n in names)
|
|
1335
|
+
or not any(n in _WRAPPER_DECORATORS for n in names)):
|
|
1336
|
+
return None
|
|
1337
|
+
spec = _Spec()
|
|
1338
|
+
spec.named = ["input_value"]
|
|
1339
|
+
spec.has_var_kw = True
|
|
1340
|
+
return spec
|
|
1341
|
+
|
|
1342
|
+
|
|
1343
|
+
def _module_scope_defs(tree):
|
|
1344
|
+
"""{name: FunctionDef | None} for every function def the module's own
|
|
1345
|
+
scope executes — descending module-level if/try/with/for blocks but never
|
|
1346
|
+
def/class bodies (mirrors _module_scope_imports). A name defined twice
|
|
1347
|
+
maps to None (which def wins is unknowable)."""
|
|
1348
|
+
defs = {}
|
|
1349
|
+
|
|
1350
|
+
def walk(stmts):
|
|
1351
|
+
for st in stmts:
|
|
1352
|
+
if isinstance(st, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
1353
|
+
defs[st.name] = None if st.name in defs else st
|
|
1354
|
+
continue
|
|
1355
|
+
if isinstance(st, ast.ClassDef):
|
|
1356
|
+
continue
|
|
1357
|
+
for field in ("body", "orelse", "finalbody"):
|
|
1358
|
+
sub = getattr(st, field, None)
|
|
1359
|
+
if sub:
|
|
1360
|
+
walk(sub)
|
|
1361
|
+
for h in getattr(st, "handlers", None) or ():
|
|
1362
|
+
walk(h.body)
|
|
1363
|
+
|
|
1364
|
+
walk(tree.body)
|
|
1365
|
+
return defs
|
|
1366
|
+
|
|
1367
|
+
|
|
1368
|
+
def _module_scope_imports(tree):
|
|
1369
|
+
"""(from_imports, module_aliases) the module's own scope executes —
|
|
1370
|
+
descending module-level if/try/with/for blocks but never def/class bodies
|
|
1371
|
+
(those bind other scopes). from_imports is {alias: (module, name)} for
|
|
1372
|
+
absolute `from m import x`; module_aliases is {alias: module_name} for
|
|
1373
|
+
`import m` / `import m.n as p` (dotted-call bases: `imgui.dummy(...)`).
|
|
1374
|
+
Relative imports are skipped (not hop-resolvable by name)."""
|
|
1375
|
+
imports = {}
|
|
1376
|
+
modules = {}
|
|
1377
|
+
|
|
1378
|
+
def walk(stmts):
|
|
1379
|
+
for st in stmts:
|
|
1380
|
+
if isinstance(st, (ast.FunctionDef, ast.AsyncFunctionDef,
|
|
1381
|
+
ast.ClassDef)):
|
|
1382
|
+
continue
|
|
1383
|
+
if isinstance(st, ast.ImportFrom):
|
|
1384
|
+
if st.module and not st.level:
|
|
1385
|
+
for al in st.names:
|
|
1386
|
+
if al.name != "*":
|
|
1387
|
+
imports[al.asname or al.name] = (st.module, al.name)
|
|
1388
|
+
continue
|
|
1389
|
+
if isinstance(st, ast.Import):
|
|
1390
|
+
for al in st.names:
|
|
1391
|
+
if al.asname:
|
|
1392
|
+
modules[al.asname] = al.name
|
|
1393
|
+
else:
|
|
1394
|
+
# `import a.b` binds `a`; the chain walk steps to `b`.
|
|
1395
|
+
top = al.name.partition(".")[0]
|
|
1396
|
+
modules[top] = top
|
|
1397
|
+
continue
|
|
1398
|
+
for field in ("body", "orelse", "finalbody"):
|
|
1399
|
+
sub = getattr(st, field, None)
|
|
1400
|
+
if sub:
|
|
1401
|
+
walk(sub)
|
|
1402
|
+
for h in getattr(st, "handlers", None) or ():
|
|
1403
|
+
walk(h.body)
|
|
1404
|
+
|
|
1405
|
+
walk(tree.body)
|
|
1406
|
+
return imports, modules
|
|
1407
|
+
|
|
1408
|
+
|
|
1409
|
+
@lag_traced("signature-table parse", 30)
|
|
1410
|
+
def _signature_table(path):
|
|
1411
|
+
"""Call specs the file's CURRENT text (pending-save inclusive) defines at
|
|
1412
|
+
module scope, or None (unreadable/unparseable — callers err on silence).
|
|
1413
|
+
|
|
1414
|
+
{"specs": {name: _Spec | _SIG_UNKNOWN} for module-level defs/classes,
|
|
1415
|
+
"imports": {alias: (module, name)} for its `from m import x` bindings}
|
|
1416
|
+
|
|
1417
|
+
Trust rules match the buffer-static pass: decorated defs, rebound names
|
|
1418
|
+
and non-trivial classes map to _SIG_UNKNOWN (bound, but never checked).
|
|
1419
|
+
Cached on (mtime_ns, pending_gen) with the same freshness floor as
|
|
1420
|
+
_file_binds_cache — an O(file) parse, throttled, worker-side only."""
|
|
1421
|
+
try:
|
|
1422
|
+
st = os.stat(path)
|
|
1423
|
+
except (OSError, TypeError, ValueError):
|
|
1424
|
+
return None
|
|
1425
|
+
now = time.monotonic()
|
|
1426
|
+
|
|
1427
|
+
def _fresh(hit):
|
|
1428
|
+
return hit is not None and (
|
|
1429
|
+
(hit[0] == st.st_mtime_ns and hit[1] == gen)
|
|
1430
|
+
or now - hit[3] < _FILE_BINDS_MIN_INTERVAL_S)
|
|
1431
|
+
|
|
1432
|
+
gen = 0
|
|
1433
|
+
text = None
|
|
1434
|
+
key = str(path)
|
|
1435
|
+
try:
|
|
1436
|
+
from meltygui.editor.pending_save import PendingSave
|
|
1437
|
+
from pathlib import Path as _P
|
|
1438
|
+
rp = _P(path).resolve()
|
|
1439
|
+
key = str(rp)
|
|
1440
|
+
gen = PendingSave.pending_gen_for(rp)
|
|
1441
|
+
hit = _file_sig_cache.get(key)
|
|
1442
|
+
if _fresh(hit):
|
|
1443
|
+
return hit[2]
|
|
1444
|
+
text = PendingSave.current_file_text(rp)
|
|
1445
|
+
except Exception:
|
|
1446
|
+
hit = _file_sig_cache.get(key)
|
|
1447
|
+
if _fresh(hit):
|
|
1448
|
+
return hit[2]
|
|
1449
|
+
try:
|
|
1450
|
+
with open(path, encoding="utf-8", errors="replace") as f:
|
|
1451
|
+
text = f.read()
|
|
1452
|
+
except OSError:
|
|
1453
|
+
text = None
|
|
1454
|
+
table = None
|
|
1455
|
+
if text is not None:
|
|
1456
|
+
try:
|
|
1457
|
+
tree = _parse_for_analysis(text)
|
|
1458
|
+
col = _Collector()
|
|
1459
|
+
col.run(tree)
|
|
1460
|
+
specs = {}
|
|
1461
|
+
# Walked directly (not col.module.defs - the collector drops every
|
|
1462
|
+
# decorated def) so @command/@render_func defs get their convention
|
|
1463
|
+
# specs; rebound names still fall to unknowable via `ambiguous`.
|
|
1464
|
+
for name, node in _module_scope_defs(tree).items():
|
|
1465
|
+
if node is None or name in col.module.ambiguous:
|
|
1466
|
+
specs[name] = _SIG_UNKNOWN
|
|
1467
|
+
continue
|
|
1468
|
+
flavor = _decorator_flavor(node.decorator_list)
|
|
1469
|
+
spec = (_spec_from_arguments(node.args)
|
|
1470
|
+
if flavor in ("plain", "static")
|
|
1471
|
+
else _wrapper_spec_for(node.decorator_list))
|
|
1472
|
+
specs[name] = spec if spec is not None else _SIG_UNKNOWN
|
|
1473
|
+
for name, (node, cls_scope) in col.module.classes.items():
|
|
1474
|
+
specs[name] = _class_call_spec(node, cls_scope)
|
|
1475
|
+
imports, modules = _module_scope_imports(tree)
|
|
1476
|
+
for d in (imports, modules):
|
|
1477
|
+
for alias in list(d):
|
|
1478
|
+
# A name the module also defs/rebinds isn't the import target.
|
|
1479
|
+
if alias in specs or alias in col.module.ambiguous:
|
|
1480
|
+
del d[alias]
|
|
1481
|
+
table = {"specs": specs, "imports": imports, "modules": modules}
|
|
1482
|
+
except (SyntaxError, ValueError, RecursionError, TypeError):
|
|
1483
|
+
table = None
|
|
1484
|
+
if table is None and hit is not None and hit[2] is not None:
|
|
1485
|
+
# Known-good: the pending text is unparseable exactly while the cursor is
|
|
1486
|
+
# MID-KEYSTROKE in some span of this file - a None here would blank
|
|
1487
|
+
# every signature marker built from the previous good parse (the
|
|
1488
|
+
# flash-then-vanish bug) so the stale table serves until a clean
|
|
1489
|
+
# parse replaces it. Signatures rarely change in the broken window.
|
|
1490
|
+
table = hit[2]
|
|
1491
|
+
_file_sig_cache[key] = (st.st_mtime_ns, gen, table, now)
|
|
1492
|
+
return table
|
|
1493
|
+
|
|
1494
|
+
|
|
1495
|
+
def _pending_spec_for(obj):
|
|
1496
|
+
"""(handled, spec) — the spec for `obj` from its defining file's PENDING
|
|
1497
|
+
text. handled=True means the pending source answered authoritatively
|
|
1498
|
+
(spec may be None = deliberately unknowable → silence); handled=False
|
|
1499
|
+
means no pending edits / not locatable → use live introspection."""
|
|
1500
|
+
if isinstance(obj, type):
|
|
1501
|
+
mod = sys.modules.get(getattr(obj, "__module__", None) or "")
|
|
1502
|
+
file = getattr(mod, "__file__", None)
|
|
1503
|
+
qual = getattr(obj, "__qualname__", None)
|
|
1504
|
+
else:
|
|
1505
|
+
fn = obj
|
|
1506
|
+
for _ in range(8): # unwrap decorators to the def's real code
|
|
1507
|
+
inner = getattr(fn, "__wrapped__", None)
|
|
1508
|
+
if inner is None:
|
|
1509
|
+
break
|
|
1510
|
+
fn = inner
|
|
1511
|
+
code = getattr(fn, "__code__", None)
|
|
1512
|
+
if code is None:
|
|
1513
|
+
return False, None
|
|
1514
|
+
file = code.co_filename
|
|
1515
|
+
qual = getattr(fn, "__qualname__", None)
|
|
1516
|
+
if (not file or not qual or "." in qual):
|
|
1517
|
+
return False, None # only module-level names are in the table
|
|
1518
|
+
try:
|
|
1519
|
+
from meltygui.editor.pending_save import PendingSave
|
|
1520
|
+
from pathlib import Path as _P
|
|
1521
|
+
rp = _P(file).resolve()
|
|
1522
|
+
if PendingSave.pending_gen_for(rp) <= 0:
|
|
1523
|
+
return False, None # no unsaved edits - live/disk agree
|
|
1524
|
+
except Exception:
|
|
1525
|
+
return False, None
|
|
1526
|
+
table = _signature_table(str(rp))
|
|
1527
|
+
if table is None:
|
|
1528
|
+
return False, None
|
|
1529
|
+
spec = table["specs"].get(qual, _MISS)
|
|
1530
|
+
if spec is _MISS:
|
|
1531
|
+
return False, None # def moved/renamed - fall back to live
|
|
1532
|
+
return True, (None if spec is _SIG_UNKNOWN else spec)
|
|
1533
|
+
|
|
1534
|
+
|
|
1535
|
+
def _object_spec(obj, fname):
|
|
1536
|
+
"""The spec a call to `obj` checks against: the pending text of its
|
|
1537
|
+
defining file when that file has unsaved edits (the live signature goes
|
|
1538
|
+
stale between an edit and its recompile), else live introspection."""
|
|
1539
|
+
try:
|
|
1540
|
+
handled, spec = _pending_spec_for(obj)
|
|
1541
|
+
except Exception:
|
|
1542
|
+
handled, spec = False, None
|
|
1543
|
+
if handled:
|
|
1544
|
+
return spec
|
|
1545
|
+
return _live_spec(obj, fname)
|
|
1546
|
+
|
|
1547
|
+
|
|
1548
|
+
def _span_spec_for_live(obj, fname):
|
|
1549
|
+
"""Spec for a LIVE object reached from span mode, or None. Stricter than
|
|
1550
|
+
the whole-file live pass: only real function objects are trusted.
|
|
1551
|
+
Classes lie (ast.Constant's signature claims required params its
|
|
1552
|
+
constructor doesn't enforce) and callable INSTANCES lie (PyOpenGL's
|
|
1553
|
+
glDrawBuffers wrapper hides its signature and its doc line parses into
|
|
1554
|
+
the wrong arity) — both produce false alarms, so they stay silent."""
|
|
1555
|
+
if isinstance(obj, type) or not (_is_cython_function(obj) or isinstance(
|
|
1556
|
+
obj, (types.FunctionType, types.BuiltinFunctionType))):
|
|
1557
|
+
return None
|
|
1558
|
+
return _object_spec(obj, fname)
|
|
1559
|
+
|
|
1560
|
+
def _check_dotted_call_span(call, func, scope, path):
|
|
1561
|
+
"""Span-mode signature check for a dotted call (`imgui.dummy(...)`,
|
|
1562
|
+
`mod.helper(...)`): the base name resolves through the module file's
|
|
1563
|
+
import bindings (its own text, so a base the buffer shadows never
|
|
1564
|
+
matches), then the chain walks LIVE modules only — the same modules-only
|
|
1565
|
+
rule as _walk_chain, minus the missing-attr report (out of scope for the
|
|
1566
|
+
span pass). Anything unresolvable → silence."""
|
|
1567
|
+
base, attrs = _unwind_chain(func)
|
|
1568
|
+
if base is None or _resolves(scope, base.id):
|
|
1569
|
+
return None
|
|
1570
|
+
table = _signature_table(path) if path else None
|
|
1571
|
+
if table is None:
|
|
1572
|
+
return None
|
|
1573
|
+
obj = _MISS
|
|
1574
|
+
modname = table["modules"].get(base.id)
|
|
1575
|
+
if modname is not None:
|
|
1576
|
+
mod = sys.modules.get(modname)
|
|
1577
|
+
obj = mod if mod is not None else _MISS
|
|
1578
|
+
else:
|
|
1579
|
+
imp = table["imports"].get(base.id)
|
|
1580
|
+
if imp is not None:
|
|
1581
|
+
mod = sys.modules.get(imp[0])
|
|
1582
|
+
try:
|
|
1583
|
+
obj = vars(mod).get(imp[1], _MISS) if mod is not None else _MISS
|
|
1584
|
+
except TypeError:
|
|
1585
|
+
obj = _MISS
|
|
1586
|
+
if obj is _MISS:
|
|
1587
|
+
return None
|
|
1588
|
+
for attr, _node in attrs:
|
|
1589
|
+
if not isinstance(obj, types.ModuleType):
|
|
1590
|
+
return None # never walk through functions/classes
|
|
1591
|
+
obj = inspect.getattr_static(obj, attr, _MISS)
|
|
1592
|
+
if obj is _MISS:
|
|
1593
|
+
return None
|
|
1594
|
+
if not callable(obj):
|
|
1595
|
+
return None
|
|
1596
|
+
fname = attrs[-1][0]
|
|
1597
|
+
spec = _span_spec_for_live(obj, fname)
|
|
1598
|
+
return _match_spec(fname, spec, call) if spec is not None else None
|
|
1599
|
+
|
|
1600
|
+
|
|
1601
|
+
def _check_call_span(call, scope, path, file_binds):
|
|
1602
|
+
"""Signature check for SPAN buffers (only_missing_imports mode), where the
|
|
1603
|
+
live-ctx pass can't run (the buffer binds none of its module's names). A
|
|
1604
|
+
bare-name call the buffer itself doesn't bind resolves through the
|
|
1605
|
+
enclosing module's CURRENT text (_signature_table — pending-save
|
|
1606
|
+
inclusive, so a signature edited in another view flags wrong call sites
|
|
1607
|
+
before any recompile): a module-level def/class directly, a
|
|
1608
|
+
`from m import name` via the live module (upgraded to m's pending text
|
|
1609
|
+
when m's file has unsaved edits), and builtins only when the module's
|
|
1610
|
+
text provably doesn't shadow the name. Anything else → silence."""
|
|
1611
|
+
func = call.func
|
|
1612
|
+
if isinstance(func, ast.Attribute):
|
|
1613
|
+
return _check_dotted_call_span(call, func, scope, path)
|
|
1614
|
+
if not isinstance(func, ast.Name):
|
|
1615
|
+
return None
|
|
1616
|
+
name = func.id
|
|
1617
|
+
if _resolves(scope, name):
|
|
1618
|
+
return None # the buffer's own binding, static pass owns it
|
|
1619
|
+
table = _signature_table(path) if path else None
|
|
1620
|
+
if table is None:
|
|
1621
|
+
return None
|
|
1622
|
+
spec = table["specs"].get(name, _MISS)
|
|
1623
|
+
if spec is _SIG_UNKNOWN:
|
|
1624
|
+
return None
|
|
1625
|
+
if spec is not _MISS:
|
|
1626
|
+
return _match_spec(name, spec, call)
|
|
1627
|
+
imp = table["imports"].get(name)
|
|
1628
|
+
if imp is not None:
|
|
1629
|
+
mod = sys.modules.get(imp[0])
|
|
1630
|
+
|
|
1631
|
+
try:
|
|
1632
|
+
obj = vars(mod).get(imp[1], _MISS) if mod is not None else _MISS
|
|
1633
|
+
except TypeError:
|
|
1634
|
+
obj = _MISS
|
|
1635
|
+
if obj is _MISS or not callable(obj):
|
|
1636
|
+
return None
|
|
1637
|
+
spec = _span_spec_for_live(obj, name)
|
|
1638
|
+
return _match_spec(name, spec, call) if spec is not None else None
|
|
1639
|
+
if file_binds is not None and name not in file_binds:
|
|
1640
|
+
obj = getattr(builtins, name, _MISS)
|
|
1641
|
+
if obj is not _MISS and callable(obj):
|
|
1642
|
+
spec = _live_spec(obj, name)
|
|
1643
|
+
return _match_spec(name, spec, call) if spec is not None else None
|
|
1644
|
+
return None
|
|
1645
|
+
|
|
1646
|
+
|
|
1647
|
+
def _buffer_bound_names(text):
|
|
1648
|
+
"""Every name the buffer text plausibly BINDS — local vars, params,
|
|
1649
|
+
def/class names, loop/with targets, import statements — in a handful of
|
|
1650
|
+
regex passes (findall, C-speed), never per-name. Approximate on purpose,
|
|
1651
|
+
erring toward "bound" (a bound name is merely never suggested — silence
|
|
1652
|
+
beats noise). Used where the buffer's parse can't be trusted (mid-edit
|
|
1653
|
+
syntax errors)."""
|
|
1654
|
+
import re
|
|
1655
|
+
bound = set()
|
|
1656
|
+
bound.update(_analysis_matches(r"(?m)^\s*(?:def|class)\s+(\w+)", text))
|
|
1657
|
+
bound.update(_analysis_matches(r"(?m)^\s*(\w+)\s*(?:=[^=]|,|\)|=$)", text))
|
|
1658
|
+
bound.update(_analysis_matches(r"\b(?:as|for)\s+(\w+)", text))
|
|
1659
|
+
# Tuple targets: every name between `for` and `in` is a binding
|
|
1660
|
+
# (`for chev, step, vid in ...` - the pass above only got `chev`), and
|
|
1661
|
+
# likewise every name on the left of an unpack assignment (`a, b = ...`).
|
|
1662
|
+
# `(` is excluded from the assignment class so a call's kwargs
|
|
1663
|
+
# (`foo(bar, baz=1)`) are never read as targets.
|
|
1664
|
+
for targets in _analysis_matches(r"\bfor\s+([\w\s,()\[\]*]+?)\s+in\b", text):
|
|
1665
|
+
bound.update(re.findall(r"\w+", targets))
|
|
1666
|
+
for targets in _analysis_matches(r"(?m)^\s*([\w\s,\[\]*]+?)\s*=[^=]", text):
|
|
1667
|
+
bound.update(re.findall(r"\w+", targets))
|
|
1668
|
+
for params in _analysis_matches(r"(?m)^\s*(?:def\s+\w+|lambda)\s*\(([^)]*)", text):
|
|
1669
|
+
bound.update(re.findall(r"\w+", params))
|
|
1670
|
+
# Import bindings at ANY indent - a function-local `from m import name`
|
|
1671
|
+
# covers `name` for the whole buffer's scope, so it must never be
|
|
1672
|
+
# re-suggested. The parenthesized form is captured ACROSS lines ([^)]
|
|
1673
|
+
# matches newlines), so EVERY name of a multi-line
|
|
1674
|
+
# `from m import (a,\n b, c)` binds, not just the first per line.
|
|
1675
|
+
for names in _analysis_matches(
|
|
1676
|
+
r"(?m)^\s*from\s+[.\w]+\s+import\s+(\([^)]*\)?|[^#\n]*)", text):
|
|
1677
|
+
names = names.strip("()")
|
|
1678
|
+
for part in names.split(","):
|
|
1679
|
+
toks = part.split()
|
|
1680
|
+
if toks and toks[0] != "*":
|
|
1681
|
+
bound.add(toks[0])
|
|
1682
|
+
for names in _analysis_matches(r"(?m)^\s*import\s+([^#\n]+)", text):
|
|
1683
|
+
for part in names.split(","):
|
|
1684
|
+
toks = part.split()
|
|
1685
|
+
if toks:
|
|
1686
|
+
bound.add(toks[0].split(".")[0])
|
|
1687
|
+
return bound
|
|
1688
|
+
|
|
1689
|
+
|
|
1690
|
+
def _tokenize_lenient(slice_text):
|
|
1691
|
+
"""[(type, string, rel_line)] NAME/OP tokens for a slice. Whole-slice
|
|
1692
|
+
tokenize first; where it BREAKS (a mid-edit dedent mismatch — e.g. an
|
|
1693
|
+
appended line shallower than the line above — or an unterminated string)
|
|
1694
|
+
the remaining lines are tokenized INDIVIDUALLY, stripped so indentation
|
|
1695
|
+
can't fault. The scan's filters only use prev/next context within a
|
|
1696
|
+
line, so per-line context is enough; without the salvage every line
|
|
1697
|
+
after the break silently vanished from the scan."""
|
|
1698
|
+
import io
|
|
1699
|
+
import tokenize as _tokenize
|
|
1700
|
+
out = []
|
|
1701
|
+
broke = False
|
|
1702
|
+
try:
|
|
1703
|
+
for index, tok in enumerate(_tokenize.generate_tokens(io.StringIO(slice_text).readline)):
|
|
1704
|
+
if index % 128 == 0:
|
|
1705
|
+
_analysis_checkpoint()
|
|
1706
|
+
if tok.type in (_tokenize.NAME, _tokenize.OP):
|
|
1707
|
+
out.append((tok.type, tok.string, tok.start[0]))
|
|
1708
|
+
except (_tokenize.TokenError, IndentationError, SyntaxError, ValueError):
|
|
1709
|
+
broke = True
|
|
1710
|
+
lines = slice_text.split("\n")
|
|
1711
|
+
# Salvage ONLY small slices (the incremental region case, where the break
|
|
1712
|
+
# is the edit). A whole-buffer scan that breaks mid-file must NOT be
|
|
1713
|
+
# salvaged: per-line mode has no string context, so thousands of
|
|
1714
|
+
# docstring lines below the break would tokenize as code and flood the
|
|
1715
|
+
# UI with prose "suggestions". Losing the below-break findings for
|
|
1716
|
+
# one mid-edit scan is the old, silent behavior - the incremental state
|
|
1717
|
+
# keeps the previous findings for those lines anyway.
|
|
1718
|
+
if broke and len(lines) <= 200:
|
|
1719
|
+
resume = max((ln for _t, _s, ln in out), default=0) + 1
|
|
1720
|
+
for idx in range(resume - 1, len(lines)):
|
|
1721
|
+
stripped = lines[idx].strip()
|
|
1722
|
+
if not stripped:
|
|
1723
|
+
continue
|
|
1724
|
+
try:
|
|
1725
|
+
for tok in _tokenize.generate_tokens(
|
|
1726
|
+
io.StringIO(stripped + "\n").readline):
|
|
1727
|
+
if tok.type in (_tokenize.NAME, _tokenize.OP):
|
|
1728
|
+
out.append((tok.type, tok.string, idx + 1))
|
|
1729
|
+
except (_tokenize.TokenError, IndentationError, SyntaxError,
|
|
1730
|
+
ValueError):
|
|
1731
|
+
continue
|
|
1732
|
+
return out
|
|
1733
|
+
|
|
1734
|
+
|
|
1735
|
+
def _scan_slice(slice_text, line_offset, bound, path, cached_only=False):
|
|
1736
|
+
"""{absolute 1-based line: [import stmts]} for one text slice — the
|
|
1737
|
+
tokenize-based candidate pass shared by the full and incremental scans.
|
|
1738
|
+
Base identifiers only (not attributes after a dot, not assignment
|
|
1739
|
+
targets, not keywords / import / decorator lines); a name survives when
|
|
1740
|
+
neither the module's current text (_module_text_binds) nor `bound` (the
|
|
1741
|
+
buffer's own bindings) accounts for it AND an import statement would
|
|
1742
|
+
bind it (_suggest_import, cached per name)."""
|
|
1743
|
+
import keyword
|
|
1744
|
+
import tokenize as _tokenize
|
|
1745
|
+
toks = _tokenize_lenient(slice_text)
|
|
1746
|
+
if not toks:
|
|
1747
|
+
return {}
|
|
1748
|
+
file_binds = None
|
|
1749
|
+
file_binds_ready = False
|
|
1750
|
+
lines = slice_text.split("\n")
|
|
1751
|
+
verdict = {} # name -> [stmts] or None (checked once)
|
|
1752
|
+
out = {}
|
|
1753
|
+
for i, (kind, s, rel_line) in enumerate(toks):
|
|
1754
|
+
if kind != _tokenize.NAME or keyword.iskeyword(s):
|
|
1755
|
+
continue
|
|
1756
|
+
stripped = (lines[rel_line - 1].strip()
|
|
1757
|
+
if 1 <= rel_line <= len(lines) else "")
|
|
1758
|
+
if stripped.startswith(("import ", "from ")):
|
|
1759
|
+
continue # import block line
|
|
1760
|
+
prev = toks[i - 1][1] if i > 0 else None
|
|
1761
|
+
nxt = toks[i + 1][1] if i + 1 < len(toks) else None
|
|
1762
|
+
# Attribute / binding position. Decorator lines scan like any other
|
|
1763
|
+
# code - the name after `@` AND its arguments are real usages an
|
|
1764
|
+
# import could fix (the old line-level `@` skip hid both).
|
|
1765
|
+
if prev in (".", "def", "class", "as", "import", "from"):
|
|
1766
|
+
continue
|
|
1767
|
+
if nxt == "=": # plain assignment target (== is one token)
|
|
1768
|
+
continue
|
|
1769
|
+
if s not in verdict:
|
|
1770
|
+
stmts = None
|
|
1771
|
+
if s not in _BUILTIN_NAMES and s not in bound:
|
|
1772
|
+
if not file_binds_ready:
|
|
1773
|
+
# Lazy: most slices resolve every name via builtins/bound
|
|
1774
|
+
# and never need the (cached/throttled) file parse.
|
|
1775
|
+
if cached_only:
|
|
1776
|
+
# The render-thread incremental path must not parse the
|
|
1777
|
+
# module or discover imports. A miss belongs to relint.
|
|
1778
|
+
hit = _file_binds_cache.get(str(path)) if path else None
|
|
1779
|
+
if path and hit is None:
|
|
1780
|
+
return None
|
|
1781
|
+
file_binds = hit[2] if hit else None
|
|
1782
|
+
else:
|
|
1783
|
+
file_binds = _module_text_binds(path) if path else None
|
|
1784
|
+
file_binds_ready = True
|
|
1785
|
+
if not (file_binds is not None and s in file_binds):
|
|
1786
|
+
try:
|
|
1787
|
+
stmts = _suggest_import_for_path(s, path, cached_only=cached_only)
|
|
1788
|
+
if cached_only and stmts is None:
|
|
1789
|
+
return None
|
|
1790
|
+
stmts = stmts or None
|
|
1791
|
+
except Exception:
|
|
1792
|
+
stmts = None
|
|
1793
|
+
verdict[s] = stmts
|
|
1794
|
+
stmts = verdict[s]
|
|
1795
|
+
if stmts:
|
|
1796
|
+
row = out.setdefault(line_offset + rel_line, [])
|
|
1797
|
+
for st in stmts:
|
|
1798
|
+
if st not in row:
|
|
1799
|
+
row.append(st)
|
|
1800
|
+
return out
|
|
1801
|
+
|
|
1802
|
+
|
|
1803
|
+
# Incremental scan state, one entry per lint_path: the last scan text, its
|
|
1804
|
+
# result, and the buffer's bound-name set. Two buffers sharing a path (two
|
|
1805
|
+
# span editors of one file) are back to full rescans - never wrong.
|
|
1806
|
+
_inc_scan_state = {}
|
|
1807
|
+
|
|
1808
|
+
# An edit region larger than this re-runs the full scan instead (the diff
|
|
1809
|
+
# bookkeeping stops being cheaper than one pass).
|
|
1810
|
+
_INC_MAX_REGION_CHARS = 4096
|
|
1811
|
+
|
|
1812
|
+
|
|
1813
|
+
def has_scan_state(path):
|
|
1814
|
+
"""True when a background pass already warmed `path`'s incremental scan
|
|
1815
|
+
state — the editor's per-keystroke fast path (text_editor.py) probes this
|
|
1816
|
+
before calling collect_import_suggestions on the RENDER thread: a warm
|
|
1817
|
+
incremental step is O(changed region) (~0.4ms), but a path's FIRST scan is
|
|
1818
|
+
O(buffer tokenize + module-binds parse) and belongs on a worker."""
|
|
1819
|
+
return path is not None and str(path) in _inc_scan_state
|
|
1820
|
+
|
|
1821
|
+
|
|
1822
|
+
@lag_traced("import scan", 30)
|
|
1823
|
+
def collect_import_suggestions(text, path=None, full=False,
|
|
1824
|
+
incremental_only=False):
|
|
1825
|
+
"""{1-based line: [import statements]} for every symbol the buffer USES
|
|
1826
|
+
but nothing binds — the editor's Alt+Enter quick-fix data, a SEPARATE
|
|
1827
|
+
channel from the error lint. Tokenize-based, so it works mid-edit (a
|
|
1828
|
+
dangling `json.` breaks the parse, not the tokenizer).
|
|
1829
|
+
|
|
1830
|
+
INCREMENTAL per keystroke: the previous text/result are kept per path,
|
|
1831
|
+
the edit is located by common prefix/suffix (C-speed string ops), only
|
|
1832
|
+
the changed LINES are re-tokenized, and every unchanged line's findings
|
|
1833
|
+
are shifted, not recomputed — so a keystroke costs O(changed region),
|
|
1834
|
+
never O(buffer). `full=True` (the relint path — the file's import block
|
|
1835
|
+
may have changed) and structural cases (first scan, big paste, edits
|
|
1836
|
+
inside triple-quoted strings, tokenizer trouble) run the whole pass.
|
|
1837
|
+
|
|
1838
|
+
`incremental_only=True` (the editor's render-thread fast path on large
|
|
1839
|
+
buffers) returns None instead of running that O(buffer) full pass — the
|
|
1840
|
+
caller falls back to the debounced background channel, whose next run
|
|
1841
|
+
re-warms the state here."""
|
|
1842
|
+
key = str(path) if path else None
|
|
1843
|
+
st = _inc_scan_state.get(key) if key else None
|
|
1844
|
+
if not full and st is not None:
|
|
1845
|
+
old = st["text"]
|
|
1846
|
+
if old is text or old == text:
|
|
1847
|
+
return st["result"]
|
|
1848
|
+
inc = _incremental_scan(st, old, text, path, cached_only=incremental_only)
|
|
1849
|
+
if inc is not None:
|
|
1850
|
+
if key:
|
|
1851
|
+
_inc_scan_state[key] = inc
|
|
1852
|
+
return inc["result"]
|
|
1853
|
+
if incremental_only:
|
|
1854
|
+
return None
|
|
1855
|
+
bound = _buffer_bound_names(text)
|
|
1856
|
+
result = _scan_slice(text, 0, bound, path)
|
|
1857
|
+
if key:
|
|
1858
|
+
_inc_scan_state[key] = {"text": text, "result": result, "bound": bound}
|
|
1859
|
+
return result
|
|
1860
|
+
|
|
1861
|
+
|
|
1862
|
+
def _incremental_scan(st, old, text, path, cached_only=False):
|
|
1863
|
+
"""The O(changed region) path: new state dict, or None → run a full scan.
|
|
1864
|
+
|
|
1865
|
+
The changed region is the line span between the common prefix and common
|
|
1866
|
+
suffix. Findings on lines before it are kept as-is, lines after it shift
|
|
1867
|
+
by the line-count delta, and the region itself is re-tokenized in
|
|
1868
|
+
isolation. The bound-name set only GROWS here (bindings added in the
|
|
1869
|
+
region); a binding DELETED elsewhere keeps its name suppressed until the
|
|
1870
|
+
next full scan — the relint kick that follows every queued save runs one
|
|
1871
|
+
within ~a second, so the miss is transient. An edit inside a triple-
|
|
1872
|
+
quoted string would tokenize prose as code, so an odd quote count before
|
|
1873
|
+
the region skips its rescan (pure line-shift instead)."""
|
|
1874
|
+
# Common prefix/suffix by CHUNKED slice compares (C-speed memcmp) - a
|
|
1875
|
+
# per-char Python loop here costs ~25ms on a 300k buffer, which is
|
|
1876
|
+
# the exact per-keystroke stall this incremental path aims to kill.
|
|
1877
|
+
max_p = min(len(old), len(text))
|
|
1878
|
+
p = 0
|
|
1879
|
+
for step in (1 << 16, 1 << 12, 1 << 8, 1 << 4, 1):
|
|
1880
|
+
while p + step <= max_p and old[p:p + step] == text[p:p + step]:
|
|
1881
|
+
p += step
|
|
1882
|
+
max_s = max_p - p
|
|
1883
|
+
s = 0
|
|
1884
|
+
for step in (1 << 16, 1 << 12, 1 << 8, 1 << 4, 1):
|
|
1885
|
+
while (s + step <= max_s
|
|
1886
|
+
and old[len(old) - s - step:len(old) - s]
|
|
1887
|
+
== text[len(text) - s - step:len(text) - s]):
|
|
1888
|
+
s += step
|
|
1889
|
+
if len(text) - s - p > _INC_MAX_REGION_CHARS:
|
|
1890
|
+
return None # big paste/rewrite - full scan is cheaper
|
|
1891
|
+
pre_lines = text.count("\n", 0, p)
|
|
1892
|
+
old_total = old.count("\n") + 1
|
|
1893
|
+
new_total = text.count("\n") + 1
|
|
1894
|
+
suf_lines = text.count("\n", len(text) - s) if s else 0
|
|
1895
|
+
delta = new_total - old_total
|
|
1896
|
+
result = {}
|
|
1897
|
+
for ln, stmts in st["result"].items():
|
|
1898
|
+
if ln <= pre_lines:
|
|
1899
|
+
result[ln] = stmts
|
|
1900
|
+
elif ln > old_total - suf_lines:
|
|
1901
|
+
result[ln + delta] = stmts
|
|
1902
|
+
# Region slice, rounded to whole lines.
|
|
1903
|
+
start_idx = text.rfind("\n", 0, p) + 1
|
|
1904
|
+
end_idx = len(text) - s
|
|
1905
|
+
nl = text.find("\n", end_idx)
|
|
1906
|
+
slice_end = len(text) if nl == -1 else nl
|
|
1907
|
+
slice_text = text[start_idx:slice_end]
|
|
1908
|
+
bound = st["bound"]
|
|
1909
|
+
in_string = (text.count('"""', 0, start_idx)
|
|
1910
|
+
+ text.count("'''", 0, start_idx)) % 2 == 1
|
|
1911
|
+
if slice_text and not in_string:
|
|
1912
|
+
region_bound = _buffer_bound_names(slice_text)
|
|
1913
|
+
if region_bound - bound:
|
|
1914
|
+
bound = bound | region_bound
|
|
1915
|
+
findings = _scan_slice(slice_text, pre_lines, bound, path, cached_only=cached_only)
|
|
1916
|
+
if findings is None:
|
|
1917
|
+
return None
|
|
1918
|
+
for ln, stmts in findings.items():
|
|
1919
|
+
result[ln] = stmts
|
|
1920
|
+
return {"text": text, "result": result, "bound": bound}
|
|
1921
|
+
|
|
1922
|
+
|
|
1923
|
+
# ── entry point ──────────────────────────────────────────────────────────────
|
|
1924
|
+
|
|
1925
|
+
# Incremental lint state, one entry per (path, mode): the last linted text and
|
|
1926
|
+
# its findings. Two buffers sharing a path (a file class + a mid-file view)
|
|
1927
|
+
# degrade to full re-lints on each swap - never wrong, just slower.
|
|
1928
|
+
_inc_lint_state = {}
|
|
1929
|
+
|
|
1930
|
+
# An edited block larger than this skips its region re-lint (findings inside it
|
|
1931
|
+
# go stale until the next full pass) - the point of the incremental path is
|
|
1932
|
+
# bounding worker GIL-hold, so a monster block must not sneak an O(n)-
|
|
1933
|
+
# scale pass back in.
|
|
1934
|
+
_INC_LINT_REGION_CHARS = 64 * 1024
|
|
1935
|
+
|
|
1936
|
+
|
|
1937
|
+
def _changed_block_bounds(a, b):
|
|
1938
|
+
"""The edited region of line-lists `a` → `b`, expanded to enclosing
|
|
1939
|
+
top-level block(s) (nearest column-0 lines — the same expansion the
|
|
1940
|
+
editor's region compile uses). Returns (start, end_new, end_old, delta),
|
|
1941
|
+
all 0-based with exclusive ends, or None when the texts are line-identical."""
|
|
1942
|
+
na, nb = len(a), len(b)
|
|
1943
|
+
pre = 0
|
|
1944
|
+
m = min(na, nb)
|
|
1945
|
+
while pre < m and a[pre] == b[pre]:
|
|
1946
|
+
pre += 1
|
|
1947
|
+
if pre == na and pre == nb:
|
|
1948
|
+
return None
|
|
1949
|
+
suf = 0
|
|
1950
|
+
while suf < (na - pre) and suf < (nb - pre) and a[na - 1 - suf] == b[nb - 1 - suf]:
|
|
1951
|
+
suf += 1
|
|
1952
|
+
lo, hi = pre, nb - suf
|
|
1953
|
+
start = min(lo, nb - 1)
|
|
1954
|
+
while start > 0 and (not b[start] or b[start][0] in " \t"):
|
|
1955
|
+
start -= 1
|
|
1956
|
+
end = hi
|
|
1957
|
+
while end < nb and (not b[end] or b[end][0] in " \t"):
|
|
1958
|
+
end += 1
|
|
1959
|
+
return start, end, end + (na - nb), nb - na
|
|
1960
|
+
|
|
1961
|
+
|
|
1962
|
+
@lag_traced("incremental lint", 30)
|
|
1963
|
+
def check_source_incremental(text, path=None, only_missing_imports=False):
|
|
1964
|
+
"""check_source, O(edited block) per call: diff against the last linted
|
|
1965
|
+
text, keep findings outside the edited top-level block (shifted by the
|
|
1966
|
+
line delta), and re-lint only the block itself.
|
|
1967
|
+
|
|
1968
|
+
Region lint is sound here for the same reason SPAN lint is: check_source
|
|
1969
|
+
with `path` resolves names through the live module namespace and the
|
|
1970
|
+
pending-file binds (_module_text_binds), so a lone block from mid-file
|
|
1971
|
+
sees its module's imports and sibling definitions instead of flagging
|
|
1972
|
+
them. A block that doesn't parse (mid-edit) reports [] for the region —
|
|
1973
|
+
errs silent, the next clean edit re-lints it.
|
|
1974
|
+
|
|
1975
|
+
Trade-offs vs the full pass: a binding added/removed OUTSIDE the edited
|
|
1976
|
+
block doesn't re-verify findings elsewhere, and an over-sized block
|
|
1977
|
+
(>_INC_LINT_REGION_CHARS) keeps its stale findings. The first call per
|
|
1978
|
+
(path, mode) pays one full pass to seed the state."""
|
|
1979
|
+
key = (str(path) if path else None, bool(only_missing_imports))
|
|
1980
|
+
st = _inc_lint_state.get(key)
|
|
1981
|
+
if st is None:
|
|
1982
|
+
findings = check_source(text, path=path,
|
|
1983
|
+
only_missing_imports=only_missing_imports)
|
|
1984
|
+
# The buffer-wide bound-name set suppresses region findings about
|
|
1985
|
+
# names DEFINED IN OTHER BLOCKS of this buffer: a lone block can't
|
|
1986
|
+
# see them itself, and the live-module fallback only covers files
|
|
1987
|
+
# actually loaded in this process. _buffer_bound_names deliberately
|
|
1988
|
+
# over-approximates (a wrongly-suppressed finding beats a false
|
|
1989
|
+
# alarm). Grow it, using the import scanner's bound set.
|
|
1990
|
+
_inc_lint_state[key] = {"text": text, "findings": findings,
|
|
1991
|
+
"binds": _buffer_bound_names(text)}
|
|
1992
|
+
return findings
|
|
1993
|
+
old = st["text"]
|
|
1994
|
+
if old is text or old == text:
|
|
1995
|
+
return st["findings"]
|
|
1996
|
+
bounds = _changed_block_bounds(old.split("\n"), text.split("\n"))
|
|
1997
|
+
if bounds is None:
|
|
1998
|
+
st["text"] = text
|
|
1999
|
+
return st["findings"]
|
|
2000
|
+
start, end, end_old, delta = bounds
|
|
2001
|
+
# Bindings have file-wide effects. Recheck all uses when a declaration is
|
|
2002
|
+
# added/removed instead of retaining a grow-only set of old imports.
|
|
2003
|
+
previous_region = '\n'.join(old.split('\n')[start:end_old])
|
|
2004
|
+
current_region = '\n'.join(text.split('\n')[start:end])
|
|
2005
|
+
if _buffer_bound_names(previous_region) != _buffer_bound_names(current_region):
|
|
2006
|
+
_file_binds_cache.pop(str(path), None)
|
|
2007
|
+
findings = check_source(text, path=path, only_missing_imports=only_missing_imports)
|
|
2008
|
+
_inc_lint_state[key] = {'text': text, 'findings': findings,
|
|
2009
|
+
'binds': _buffer_bound_names(text)}
|
|
2010
|
+
return findings
|
|
2011
|
+
# Old-text region rows are start+1 .. end_old (1 based): findings above
|
|
2012
|
+
# keep their line, findings below shift by the edit line delta, findings
|
|
2013
|
+
# inside are re-derived from the fresh region lint.
|
|
2014
|
+
kept = [(ln, msg) if ln <= start else (ln + delta, msg)
|
|
2015
|
+
for ln, msg in st["findings"]
|
|
2016
|
+
if ln <= start or ln > end_old]
|
|
2017
|
+
region = "\n".join(text.split("\n")[start:end])
|
|
2018
|
+
if len(region) <= _INC_LINT_REGION_CHARS:
|
|
2019
|
+
binds = st.get("binds")
|
|
2020
|
+
if binds is None: # state predates the binds field
|
|
2021
|
+
binds = st["binds"] = _buffer_bound_names(old)
|
|
2022
|
+
binds |= _buffer_bound_names(region)
|
|
2023
|
+
# `from x import *` binds names _buffer_bound_names can't see (it
|
|
2024
|
+
# skips `*` on purpose) - a region using a star-imported name
|
|
2025
|
+
# (TrainingStatus in lsd_train.py) would report "not defined" even
|
|
2026
|
+
# though the full-buffer pass stays silent (star_import kills its
|
|
2027
|
+
# name pass). _module_text_binds expands star sources through their
|
|
2028
|
+
# LIVE module's exports; None (unreadable / unresolvable) degrades
|
|
2029
|
+
# to the plain binds check.
|
|
2030
|
+
try:
|
|
2031
|
+
_mod_binds = _module_text_binds(path) if path else None
|
|
2032
|
+
except Exception:
|
|
2033
|
+
_mod_binds = None
|
|
2034
|
+
try:
|
|
2035
|
+
for ln, msg in check_source(region, path=path,
|
|
2036
|
+
only_missing_imports=only_missing_imports):
|
|
2037
|
+
# Name-shape findings about a name some OTHER block in this
|
|
2038
|
+
# buffer binds are cross-block artifacts - drop them. Other
|
|
2039
|
+
# finding shapes (signature/attr) pass through untouched.
|
|
2040
|
+
if msg.startswith("name '"):
|
|
2041
|
+
_nm = msg[6:msg.find("'", 6)]
|
|
2042
|
+
if _nm in binds or (_mod_binds is not None
|
|
2043
|
+
and _nm in _mod_binds):
|
|
2044
|
+
continue
|
|
2045
|
+
kept.append((ln + start, msg))
|
|
2046
|
+
except Exception:
|
|
2047
|
+
pass
|
|
2048
|
+
kept.sort(key=lambda f: f[0])
|
|
2049
|
+
_inc_lint_state[key] = {"text": text, "findings": kept,
|
|
2050
|
+
"binds": st.get("binds")}
|
|
2051
|
+
return kept
|
|
2052
|
+
|
|
2053
|
+
|
|
2054
|
+
@lag_traced("check_source (lint)", 50)
|
|
2055
|
+
def check_source(text, path=None, max_reports=40, only_missing_imports=False):
|
|
2056
|
+
"""[(line, message)] for problems that would survive compile() but blow up
|
|
2057
|
+
at run time. Empty list when clean — or when the buffer isn't checkable
|
|
2058
|
+
(syntax error here means the parse/compile pass already reported it).
|
|
2059
|
+
|
|
2060
|
+
only_missing_imports=True is the SPAN-buffer mode (a function/class source
|
|
2061
|
+
edited on its own): the buffer legitimately uses names its module's import
|
|
2062
|
+
block binds, so a generic undefined-name report would flag every one. With
|
|
2063
|
+
`path` = the enclosing module's file, the live-module namespace suppresses
|
|
2064
|
+
everything the module actually binds; what's left is only reported when an
|
|
2065
|
+
import statement would fix it (the missing-import classification below) —
|
|
2066
|
+
a bare typo stays silent. Call-signature checks DO run in span mode
|
|
2067
|
+
(Toggles.TextEditor.lint_span_calls), resolved through the module file's
|
|
2068
|
+
pending text (_check_call_span) rather than the live ctx; the attr pass
|
|
2069
|
+
stays off (its module-walk needs import-bound names the span never
|
|
2070
|
+
sees)."""
|
|
2071
|
+
try:
|
|
2072
|
+
tree = _parse_for_analysis(text)
|
|
2073
|
+
except IndentationError:
|
|
2074
|
+
# A method/nested span arrives at its class-body indent - dedent and
|
|
2075
|
+
# retry (line numbers survive; textwrap.dedent strips only the common
|
|
2076
|
+
# prefix). string_to_cst_module does its own dedent, but this is the
|
|
2077
|
+
# lint's mirror of the same normalization.
|
|
2078
|
+
import textwrap
|
|
2079
|
+
try:
|
|
2080
|
+
tree = _parse_for_analysis(textwrap.dedent(text))
|
|
2081
|
+
except (SyntaxError, ValueError):
|
|
2082
|
+
return []
|
|
2083
|
+
except (SyntaxError, ValueError):
|
|
2084
|
+
return []
|
|
2085
|
+
col = _Collector()
|
|
2086
|
+
try:
|
|
2087
|
+
col.run(tree)
|
|
2088
|
+
except RecursionError:
|
|
2089
|
+
return []
|
|
2090
|
+
|
|
2091
|
+
live_mod = _module_for(path)
|
|
2092
|
+
ctx = _LiveCtx(live_mod, col.declared_attrs)
|
|
2093
|
+
live_names = ctx.ns.keys() if ctx.ns is not None else ()
|
|
2094
|
+
|
|
2095
|
+
reports = []
|
|
2096
|
+
seen = set()
|
|
2097
|
+
|
|
2098
|
+
def report(line, msg):
|
|
2099
|
+
if (line, msg) not in seen:
|
|
2100
|
+
seen.add((line, msg))
|
|
2101
|
+
reports.append((line, msg))
|
|
2102
|
+
|
|
2103
|
+
if path:
|
|
2104
|
+
from meltygui.core.runtime.extensions import call
|
|
2105
|
+
for line, message in call('source_diagnostics', text, path) or ():
|
|
2106
|
+
report(line, message)
|
|
2107
|
+
|
|
2108
|
+
# Span mode checks "does the module bind this" against the file's
|
|
2109
|
+
# CURRENT text (pending/inclusive), not its live namespace: a module keeps
|
|
2110
|
+
# a live binding forever once an import happens, so live-ns suppression would
|
|
2111
|
+
# never re-flag an import the user removed. None (unreadable / mid-edit /
|
|
2112
|
+
# star import) falls back to the live namespace as usual.
|
|
2113
|
+
file_binds = (_module_text_binds(path)
|
|
2114
|
+
if only_missing_imports and path else None)
|
|
2115
|
+
|
|
2116
|
+
if not col.star_import:
|
|
2117
|
+
for scope in col.scopes:
|
|
2118
|
+
_analysis_checkpoint()
|
|
2119
|
+
for index, (name, lineno) in enumerate(scope.loads):
|
|
2120
|
+
if index % 128 == 0:
|
|
2121
|
+
_analysis_checkpoint()
|
|
2122
|
+
if name in _BUILTIN_NAMES:
|
|
2123
|
+
continue
|
|
2124
|
+
if _resolves(scope, name):
|
|
2125
|
+
continue
|
|
2126
|
+
if file_binds is not None and name in file_binds:
|
|
2127
|
+
continue # the module's current text binds it
|
|
2128
|
+
if file_binds is None and only_missing_imports and name in live_names:
|
|
2129
|
+
continue # no readable file text - old suppression
|
|
2130
|
+
try:
|
|
2131
|
+
fixable = bool(_suggest_import_for_path(name, path))
|
|
2132
|
+
except Exception:
|
|
2133
|
+
fixable = False # classification must never break the lint
|
|
2134
|
+
# A fixable name reports even when the live namespace still
|
|
2135
|
+
# carries it (a removed import, or an exec-injected binding
|
|
2136
|
+
# the SOURCE never declares): the file wouldn't run from
|
|
2137
|
+
# scratch. The fix itself rides the SEPARATE suggestions
|
|
2138
|
+
# channel (collect_import_suggestions), not the message.
|
|
2139
|
+
if not fixable and (name in live_names or only_missing_imports):
|
|
2140
|
+
continue # injected-at-runtime / a module global we
|
|
2141
|
+
# can't see - unfixable, stay silent
|
|
2142
|
+
report(lineno, f"name '{name}' is not defined")
|
|
2143
|
+
|
|
2144
|
+
if only_missing_imports:
|
|
2145
|
+
# Span buffers get the call-signature pass too, resolved through the
|
|
2146
|
+
# module file's PENDING text instead of the live namespace (which needs
|
|
2147
|
+
# import-bound names the span never sees) - see _check_call_span.
|
|
2148
|
+
try:
|
|
2149
|
+
from meltygui.core.runtime.toggles import Toggles
|
|
2150
|
+
_span_calls = Toggles.TextEditor.lint_span_calls
|
|
2151
|
+
except Exception:
|
|
2152
|
+
_span_calls = True
|
|
2153
|
+
if _span_calls and not col.star_import:
|
|
2154
|
+
for call, scope, guarded in col.calls:
|
|
2155
|
+
if guarded:
|
|
2156
|
+
continue
|
|
2157
|
+
msg = _check_call_static(call, scope)
|
|
2158
|
+
if msg is None:
|
|
2159
|
+
try:
|
|
2160
|
+
msg = _check_call_span(call, scope, path, file_binds)
|
|
2161
|
+
except Exception:
|
|
2162
|
+
msg = None # resolution must never break the lint
|
|
2163
|
+
if msg is not None:
|
|
2164
|
+
report(call.lineno, msg)
|
|
2165
|
+
reports.sort()
|
|
2166
|
+
return reports[:max_reports]
|
|
2167
|
+
|
|
2168
|
+
# Same toggle as the span pass - the pending-table fallback below is the
|
|
2169
|
+
# same feature surfaced in whole-file/region mode.
|
|
2170
|
+
try:
|
|
2171
|
+
from meltygui.core.runtime.toggles import Toggles
|
|
2172
|
+
_table_calls = Toggles.TextEditor.lint_span_calls
|
|
2173
|
+
except Exception:
|
|
2174
|
+
_table_calls = True
|
|
2175
|
+
for index, (call, scope, guarded) in enumerate(col.calls):
|
|
2176
|
+
if index % 128 == 0:
|
|
2177
|
+
_analysis_checkpoint()
|
|
2178
|
+
msg = _check_call_static(call, scope)
|
|
2179
|
+
if msg is None and not guarded:
|
|
2180
|
+
try:
|
|
2181
|
+
msg = _check_call_live(ctx, call, scope)
|
|
2182
|
+
except Exception:
|
|
2183
|
+
msg = None # live introspection should never break the lint
|
|
2184
|
+
if (msg is None and not guarded and _table_calls
|
|
2185
|
+
and path and not col.star_import):
|
|
2186
|
+
# Pending-table fallback: the INCREMENTAL whole-file path lints
|
|
2187
|
+
# one edited top-level block in isolation, where a sibling def
|
|
2188
|
+
# (flat_button) is neither in the buffer's scopes nor live-
|
|
2189
|
+
# resolvable; the module file's pending text alone knows it.
|
|
2190
|
+
# file_binds=None: full mode's live pass already covered builtins.
|
|
2191
|
+
try:
|
|
2192
|
+
msg = _check_call_span(call, scope, path, None)
|
|
2193
|
+
except Exception:
|
|
2194
|
+
msg = None
|
|
2195
|
+
if msg is not None:
|
|
2196
|
+
report(call.lineno, msg)
|
|
2197
|
+
|
|
2198
|
+
for base, attrs, scope, guarded in col.attr_chains:
|
|
2199
|
+
if guarded:
|
|
2200
|
+
continue
|
|
2201
|
+
try:
|
|
2202
|
+
_, rep = _walk_chain(ctx, scope, base, attrs)
|
|
2203
|
+
except Exception:
|
|
2204
|
+
rep = None
|
|
2205
|
+
if rep is not None:
|
|
2206
|
+
report(*rep)
|
|
2207
|
+
|
|
2208
|
+
reports.sort()
|
|
2209
|
+
return reports[:max_reports]
|