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.
Files changed (372) hide show
  1. meltygui/__init__.py +107 -0
  2. meltygui/accounts/__init__.py +0 -0
  3. meltygui/accounts/internet_accounts.py +1355 -0
  4. meltygui/chat/__init__.py +91 -0
  5. meltygui/chat/activity.py +75 -0
  6. meltygui/chat/backends.py +36 -0
  7. meltygui/chat/chat_interface.py +732 -0
  8. meltygui/chat/chat_proxy.py +352 -0
  9. meltygui/chat/codex_proxy.py +592 -0
  10. meltygui/chat/codex_settings.py +100 -0
  11. meltygui/chat/codex_transport.py +60 -0
  12. meltygui/chat/command_parser.py +204 -0
  13. meltygui/chat/images.py +227 -0
  14. meltygui/chat/messages.py +417 -0
  15. meltygui/chat/metadata.py +139 -0
  16. meltygui/chat/writer_locks.py +64 -0
  17. meltygui/code/__init__.py +0 -0
  18. meltygui/code/basic_converters.py +533 -0
  19. meltygui/code/chain_converters.py +2111 -0
  20. meltygui/code/code_checks.py +2209 -0
  21. meltygui/code/core_syntax.py +1430 -0
  22. meltygui/code/file_converters.py +1933 -0
  23. meltygui/code/fileref.py +702 -0
  24. meltygui/code/hotswap_guard.py +144 -0
  25. meltygui/code/libcst_conversion.py +9724 -0
  26. meltygui/code/live_instrument.py +392 -0
  27. meltygui/code/live_view.py +2490 -0
  28. meltygui/code/melty_scan.py +2684 -0
  29. meltygui/code/new_codecs.py +1255 -0
  30. meltygui/code/new_converters.py +3017 -0
  31. meltygui/code/project_code.py +278 -0
  32. meltygui/code/source_context.py +63 -0
  33. meltygui/code/symbol_roster.py +1588 -0
  34. meltygui/code/syntax_check.py +34 -0
  35. meltygui/code/syntax_check_worker.py +114 -0
  36. meltygui/completion/__init__.py +0 -0
  37. meltygui/completion/fim.py +1232 -0
  38. meltygui/completion/fim_context.py +481 -0
  39. meltygui/completion/providers/__init__.py +0 -0
  40. meltygui/completion/providers/anthropic_oauth.py +446 -0
  41. meltygui/completion/providers/anthropic_requests.py +69 -0
  42. meltygui/completion/providers/claude.py +203 -0
  43. meltygui/completion/providers/claude_usage.py +499 -0
  44. meltygui/completion/providers/codex_accounts.py +180 -0
  45. meltygui/completion/providers/copilot.py +617 -0
  46. meltygui/completion/providers/oauth_popup.py +220 -0
  47. meltygui/completion/providers/ollama.py +320 -0
  48. meltygui/completion/providers/profiles.py +23 -0
  49. meltygui/core/README.md +88 -0
  50. meltygui/core/__init__.py +1 -0
  51. meltygui/core/automation/__init__.py +1 -0
  52. meltygui/core/automation/action_core.py +153 -0
  53. meltygui/core/automation/collection_action.py +45 -0
  54. meltygui/core/automation/mcp_eval.py +167 -0
  55. meltygui/core/automation/mcp_hotswap.py +107 -0
  56. meltygui/core/automation/mcp_query.py +552 -0
  57. meltygui/core/automation/mcp_server.py +657 -0
  58. meltygui/core/automation/orchestration_core.py +2636 -0
  59. meltygui/core/automation/query_core.py +124 -0
  60. meltygui/core/automation/search_core.py +26 -0
  61. meltygui/core/automation/selector_core.py +340 -0
  62. meltygui/core/automation/value_core.py +1752 -0
  63. meltygui/core/cache/__init__.py +1 -0
  64. meltygui/core/cache/cache_diagnostics.py +0 -0
  65. meltygui/core/cache/invalidation_decoration.py +153 -0
  66. meltygui/core/cache/invalidation_tracker.py +43 -0
  67. meltygui/core/cache/tile_cache.py +5968 -0
  68. meltygui/core/conversion/__init__.py +1 -0
  69. meltygui/core/conversion/bubbling.py +599 -0
  70. meltygui/core/conversion/cache_tree.py +188 -0
  71. meltygui/core/conversion/chain.py +113 -0
  72. meltygui/core/conversion/converter_register.py +145 -0
  73. meltygui/core/conversion/data_decoration.py +67 -0
  74. meltygui/core/conversion/dict_conversion.py +1815 -0
  75. meltygui/core/conversion/dict_conversion_util.py +177 -0
  76. meltygui/core/conversion/dynamic_obj.py +89 -0
  77. meltygui/core/conversion/graph_compare.py +183 -0
  78. meltygui/core/conversion/load_save_v2.py +1032 -0
  79. meltygui/core/conversion/missing_saved_class.py +39 -0
  80. meltygui/core/conversion/path_finder.py +606 -0
  81. meltygui/core/conversion/render_host.py +1083 -0
  82. meltygui/core/core_render.py +6537 -0
  83. meltygui/core/definition_hotswap.py +231 -0
  84. meltygui/core/diagnostics/__init__.py +1 -0
  85. meltygui/core/diagnostics/attribute_churn.py +38 -0
  86. meltygui/core/diagnostics/fps_counter.py +39 -0
  87. meltygui/core/diagnostics/gpu_frame_timer.py +103 -0
  88. meltygui/core/diagnostics/inspection_core.py +169 -0
  89. meltygui/core/diagnostics/monitor_core.py +105 -0
  90. meltygui/core/diagnostics/notifications.py +706 -0
  91. meltygui/core/diagnostics/perf_trace.py +281 -0
  92. meltygui/core/diagnostics/profile_decoration.py +88 -0
  93. meltygui/core/diagnostics/resize_trace.py +62 -0
  94. meltygui/core/diagnostics/screenshot_core.py +247 -0
  95. meltygui/core/diagnostics/session_status.py +98 -0
  96. meltygui/core/diagnostics/trace_core.py +445 -0
  97. meltygui/core/files/__init__.py +1 -0
  98. meltygui/core/files/file_core.py +208 -0
  99. meltygui/core/files/file_explorer_core.py +104 -0
  100. meltygui/core/files/file_tree_core.py +198 -0
  101. meltygui/core/files/file_watch_core.py +43 -0
  102. meltygui/core/files/import_graph_core.py +43 -0
  103. meltygui/core/files/metadata_core.py +51 -0
  104. meltygui/core/graphics/__init__.py +1 -0
  105. meltygui/core/graphics/cuda_context_core.py +166 -0
  106. meltygui/core/graphics/cuda_interop_core.py +136 -0
  107. meltygui/core/graphics/cuda_kernel_core.py +91 -0
  108. meltygui/core/graphics/framebuffer_recorder.py +337 -0
  109. meltygui/core/graphics/gl_state.py +658 -0
  110. meltygui/core/graphics/lut_core.py +52 -0
  111. meltygui/core/graphics/overlay_renderer.py +984 -0
  112. meltygui/core/graphics/scene_target.py +180 -0
  113. meltygui/core/graphics/screenshot.py +439 -0
  114. meltygui/core/graphics/shader_func.py +478 -0
  115. meltygui/core/graphics/tensor_core.py +45 -0
  116. meltygui/core/graphics/text_texture.py +329 -0
  117. meltygui/core/graphics/wayland_color.py +635 -0
  118. meltygui/core/input/__init__.py +1 -0
  119. meltygui/core/input/collision.py +165 -0
  120. meltygui/core/input/drag_drop_core.py +1525 -0
  121. meltygui/core/input/hypr_left_drag.py +323 -0
  122. meltygui/core/input/input_core.py +245 -0
  123. meltygui/core/input/input_handler.py +1101 -0
  124. meltygui/core/input/mouse_cursor.py +355 -0
  125. meltygui/core/input/pynput_backend.py +1054 -0
  126. meltygui/core/input/space_mouse.py +338 -0
  127. meltygui/core/input/touchpad_backend.py +393 -0
  128. meltygui/core/input/view_selection.py +177 -0
  129. meltygui/core/layout/__init__.py +1 -0
  130. meltygui/core/layout/column_core.py +2153 -0
  131. meltygui/core/layout/cursor_core.py +161 -0
  132. meltygui/core/layout/dropdown_core.py +278 -0
  133. meltygui/core/layout/edge_constraints.py +155 -0
  134. meltygui/core/layout/grid_core.py +117 -0
  135. meltygui/core/layout/header_core.py +23 -0
  136. meltygui/core/layout/header_runtime.py +67 -0
  137. meltygui/core/layout/layout_core.py +87 -0
  138. meltygui/core/layout/tile_manager_core.py +591 -0
  139. meltygui/core/melty.py +6726 -0
  140. meltygui/core/module_map.json +896 -0
  141. meltygui/core/module_names.py +19 -0
  142. meltygui/core/rendering/__init__.py +1 -0
  143. meltygui/core/rendering/core_decoration.py +431 -0
  144. meltygui/core/rendering/core_render_helpers.py +328 -0
  145. meltygui/core/rendering/func_metadata.py +398 -0
  146. meltygui/core/rendering/mode.py +818 -0
  147. meltygui/core/rendering/mode_defaults.py +41 -0
  148. meltygui/core/rendering/modes.py +136 -0
  149. meltygui/core/rendering/parameter_core.py +1665 -0
  150. meltygui/core/rendering/render_dispatch.py +1891 -0
  151. meltygui/core/rendering/render_funcs.py +273 -0
  152. meltygui/core/rendering/shaped.py +312 -0
  153. meltygui/core/rendering/window_decoration.py +25 -0
  154. meltygui/core/runtime/__init__.py +1 -0
  155. meltygui/core/runtime/app.py +735 -0
  156. meltygui/core/runtime/app_session.py +140 -0
  157. meltygui/core/runtime/background.py +564 -0
  158. meltygui/core/runtime/extensions.py +53 -0
  159. meltygui/core/runtime/gc_manager.py +1163 -0
  160. meltygui/core/runtime/lifecycle.py +21 -0
  161. meltygui/core/runtime/paths.py +27 -0
  162. meltygui/core/runtime/settings.py +14 -0
  163. meltygui/core/runtime/singleton.py +16 -0
  164. meltygui/core/runtime/thread_safe_bool.py +24 -0
  165. meltygui/core/runtime/thread_signal.py +30 -0
  166. meltygui/core/runtime/toggles.py +3115 -0
  167. meltygui/core/services/__init__.py +1 -0
  168. meltygui/core/services/account_core.py +10 -0
  169. meltygui/core/services/chat_core.py +19 -0
  170. meltygui/core/services/claude_terminal_core.py +346 -0
  171. meltygui/core/services/terminal_core.py +458 -0
  172. meltygui/core/services/terminal_runtime.py +78 -0
  173. meltygui/core/styling/__init__.py +1 -0
  174. meltygui/core/styling/color_core.py +46 -0
  175. meltygui/core/styling/fonts.py +639 -0
  176. meltygui/core/styling/global_style.py +338 -0
  177. meltygui/core/styling/style.py +198 -0
  178. meltygui/core/styling/style_core.py +522 -0
  179. meltygui/core/styling/warm_start.py +149 -0
  180. meltygui/core/windowing/__init__.py +1 -0
  181. meltygui/core/windowing/backends/PYIMGUI_LICENSE +28 -0
  182. meltygui/core/windowing/backends/__init__.py +1 -0
  183. meltygui/core/windowing/backends/imgui_renderer.py +138 -0
  184. meltygui/core/windowing/backends/native_wayland.py +850 -0
  185. meltygui/core/windowing/backends/protocols/xdg-decoration-unstable-v1.xml +156 -0
  186. meltygui/core/windowing/backends/protocols/xdg-shell.xml +1420 -0
  187. meltygui/core/windowing/backends/wayland_protocol.py +101 -0
  188. meltygui/core/windowing/dock_core.py +182 -0
  189. meltygui/core/windowing/frame_geometry.py +42 -0
  190. meltygui/core/windowing/geometry_feed.py +864 -0
  191. meltygui/core/windowing/glfw_utils.py +1343 -0
  192. meltygui/core/windowing/os_frame.py +1552 -0
  193. meltygui/core/windowing/surface.py +643 -0
  194. meltygui/core/windowing/titlebar.py +1560 -0
  195. meltygui/core/windowing/titlebar_buttons.py +281 -0
  196. meltygui/core/windowing/wayland_move.py +932 -0
  197. meltygui/core/windowing/window_api.py +62 -0
  198. meltygui/core/windowing/window_constants.py +339 -0
  199. meltygui/core/windowing/window_visibility.py +162 -0
  200. meltygui/debug/__init__.py +0 -0
  201. meltygui/debug/app_view_utils.py +9 -0
  202. meltygui/editor/__init__.py +0 -0
  203. meltygui/editor/bash_syntax.py +30 -0
  204. meltygui/editor/code_line_fast.py +152 -0
  205. meltygui/editor/diff.py +139 -0
  206. meltygui/editor/external_changes.py +159 -0
  207. meltygui/editor/file_header.py +41 -0
  208. meltygui/editor/live_usage.py +169 -0
  209. meltygui/editor/live_view_views.py +1803 -0
  210. meltygui/editor/pending_save.py +1351 -0
  211. meltygui/editor/roster_tints.py +547 -0
  212. meltygui/editor/source_preview.py +16 -0
  213. meltygui/editor/source_tools.py +10 -0
  214. meltygui/editor/source_ui.py +70 -0
  215. meltygui/editor/spell_check.py +77 -0
  216. meltygui/editor/text_editor.py +9076 -0
  217. meltygui/editor/usage_picker.py +538 -0
  218. meltygui/events/__init__.py +0 -0
  219. meltygui/events/example.py +118 -0
  220. meltygui/examples/__init__.py +0 -0
  221. meltygui/examples/columns_demo.py +33 -0
  222. meltygui/examples/columns_window_demo.py +60 -0
  223. meltygui/examples/context_menu_demo.py +24 -0
  224. meltygui/examples/context_menu_window_demo.py +49 -0
  225. meltygui/examples/gui_playground.py +127 -0
  226. meltygui/examples/live_view_playground.py +256 -0
  227. meltygui/examples/lora.py +43 -0
  228. meltygui/examples/lora_data.py +59 -0
  229. meltygui/examples/lora_policies.py +67 -0
  230. meltygui/examples/mode_demo.py +49 -0
  231. meltygui/examples/modifies_demo.py +119 -0
  232. meltygui/examples/scalar_policies.py +60 -0
  233. meltygui/examples/style_layouts.py +149 -0
  234. meltygui/examples/tile_manager_demo.py +74 -0
  235. meltygui/examples/tint_demo.py +160 -0
  236. meltygui/examples/tint_functions.py +56 -0
  237. meltygui/examples/trace_demo.py +88 -0
  238. meltygui/examples/two_windows.py +36 -0
  239. meltygui/files/__init__.py +0 -0
  240. meltygui/files/fast_file_explorer.py +439 -0
  241. meltygui/gnome_extension/lsd-window-geometry@latent-descent/extension.js +237 -0
  242. meltygui/gnome_extension/lsd-window-geometry@latent-descent/lsd-window-geometry@latent-descent.iml +9 -0
  243. meltygui/gnome_extension/lsd-window-geometry@latent-descent/metadata.json +7 -0
  244. meltygui/graphics/__init__.py +6 -0
  245. meltygui/graphics/base.py +85 -0
  246. meltygui/graphics/examples.py +507 -0
  247. meltygui/graphics/executor.py +520 -0
  248. meltygui/graphics/filter.py +667 -0
  249. meltygui/graphics/filter.pyi +856 -0
  250. meltygui/graphics/generate_stubs.py +22 -0
  251. meltygui/graphics/registry.py +203 -0
  252. meltygui/graphics/shader_compiler.py +155 -0
  253. meltygui/graphics/shaders.py +1302 -0
  254. meltygui/graphics/stub_generator.py +250 -0
  255. meltygui/graphics/texture_manager.py +170 -0
  256. meltygui/graphics/texture_min_max.py +295 -0
  257. meltygui/hdr_color.py +757 -0
  258. meltygui/image_load.py +308 -0
  259. meltygui/model/__init__.py +1 -0
  260. meltygui/model/account_model.py +142 -0
  261. meltygui/model/camera_model.py +159 -0
  262. meltygui/model/chat_model.py +86 -0
  263. meltygui/model/code_model.py +62 -0
  264. meltygui/model/code_proxy_model.py +876 -0
  265. meltygui/model/collection_model.py +41 -0
  266. meltygui/model/color_model.py +127 -0
  267. meltygui/model/cuda_tensor_model.py +36 -0
  268. meltygui/model/cuda_texture_model.py +149 -0
  269. meltygui/model/dropdown_model.py +137 -0
  270. meltygui/model/file_metadata_model.py +73 -0
  271. meltygui/model/file_model.py +289 -0
  272. meltygui/model/format_model.py +390 -0
  273. meltygui/model/graph_model.py +98 -0
  274. meltygui/model/icon_model.py +1024 -0
  275. meltygui/model/import_graph_model.py +468 -0
  276. meltygui/model/layout_model.py +15 -0
  277. meltygui/model/lut_model.py +266 -0
  278. meltygui/model/search_model.py +173 -0
  279. meltygui/model/tensor_model.py +402 -0
  280. meltygui/model/terminal_model.py +329 -0
  281. meltygui/model/texture_model.py +146 -0
  282. meltygui/model/tile_model.py +24 -0
  283. meltygui/model/trace_model.py +198 -0
  284. meltygui/model/trace_report_model.py +146 -0
  285. meltygui/models/__init__.py +0 -0
  286. meltygui/models/file_meta.py +613 -0
  287. meltygui/models/function_console.py +184 -0
  288. meltygui/models/orchestration.py +48 -0
  289. meltygui/pbr.py +1576 -0
  290. meltygui/png_unfilter.c +49 -0
  291. meltygui/resources/JetBrainsMono-Regular.ttf +0 -0
  292. meltygui/resources/THIRD_PARTY_NOTICES.md +21 -0
  293. meltygui/resources/dejavu/DejaVuSans-Bold.ttf +0 -0
  294. meltygui/resources/dejavu/DejaVuSans-ExtraLight.ttf +0 -0
  295. meltygui/resources/dejavu/DejaVuSans.ttf +0 -0
  296. meltygui/resources/dejavu/LICENSE.txt +78 -0
  297. meltygui/resources/fontawesome-LICENSE.txt +121 -0
  298. meltygui/resources/fontawesome-webfont.ttf +0 -0
  299. meltygui/resources/hdri/studio_small_09_1k.hdr +0 -0
  300. meltygui/resources/jetbrains-weights/JetBrainsMono-Bold.ttf +0 -0
  301. meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraBold.ttf +0 -0
  302. meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraLight.ttf +0 -0
  303. meltygui/resources/jetbrains-weights/JetBrainsMono-Light.ttf +0 -0
  304. meltygui/resources/jetbrains-weights/JetBrainsMono-Medium.ttf +0 -0
  305. meltygui/resources/jetbrains-weights/JetBrainsMono-SemiBold.ttf +0 -0
  306. meltygui/resources/jetbrains-weights/JetBrainsMono-Thin.ttf +0 -0
  307. meltygui/resources/jetbrains-weights/OFL.txt +93 -0
  308. meltygui/resources/jetbrains-weights/README.md +2 -0
  309. meltygui/state/__init__.py +0 -0
  310. meltygui/state/account_state.py +22 -0
  311. meltygui/state/animation_state.py +97 -0
  312. meltygui/state/annotation_state.py +22 -0
  313. meltygui/state/chat_state.py +36 -0
  314. meltygui/state/code_state.py +10 -0
  315. meltygui/state/core_enums.py +59 -0
  316. meltygui/state/core_markers.py +115 -0
  317. meltygui/state/core_undo.py +1075 -0
  318. meltygui/state/file_state.py +75 -0
  319. meltygui/state/graph_state.py +26 -0
  320. meltygui/state/inspection_state.py +47 -0
  321. meltygui/state/menu_state.py +10 -0
  322. meltygui/state/model_enums.py +11 -0
  323. meltygui/state/new_core_model.py +2394 -0
  324. meltygui/state/orchestration_state.py +14 -0
  325. meltygui/state/query_state.py +24 -0
  326. meltygui/state/tensor_state.py +10 -0
  327. meltygui/state/terminal_state.py +23 -0
  328. meltygui/state/trace_state.py +32 -0
  329. meltygui/state/voxel_state.py +12 -0
  330. meltygui/text_index.py +816 -0
  331. meltygui/utils/__init__.py +0 -0
  332. meltygui/utils/jump_to_code.py +344 -0
  333. meltygui/utils/pkl_inspect.py +90 -0
  334. meltygui/utils/render_utils.py +1419 -0
  335. meltygui/view/__init__.py +1 -0
  336. meltygui/view/account_view.py +524 -0
  337. meltygui/view/action_view.py +73 -0
  338. meltygui/view/chat_decoration_view.py +112 -0
  339. meltygui/view/chat_view.py +1703 -0
  340. meltygui/view/code_view.py +2677 -0
  341. meltygui/view/collection_view.py +1185 -0
  342. meltygui/view/color_view.py +824 -0
  343. meltygui/view/control_view.py +559 -0
  344. meltygui/view/decoration_view.py +439 -0
  345. meltygui/view/diagnostic_view.py +177 -0
  346. meltygui/view/dropdown_view.py +1082 -0
  347. meltygui/view/file_view.py +1599 -0
  348. meltygui/view/graph_cuda_view.py +105 -0
  349. meltygui/view/graph_view.py +882 -0
  350. meltygui/view/header_view.py +848 -0
  351. meltygui/view/input_view.py +238 -0
  352. meltygui/view/inspection_view.py +1783 -0
  353. meltygui/view/layout_view.py +596 -0
  354. meltygui/view/lut_view.py +45 -0
  355. meltygui/view/menu_view.py +212 -0
  356. meltygui/view/orchestration_view.py +658 -0
  357. meltygui/view/query_view.py +118 -0
  358. meltygui/view/search_view.py +342 -0
  359. meltygui/view/tab_view.py +258 -0
  360. meltygui/view/tensor_view.py +430 -0
  361. meltygui/view/terminal_view.py +355 -0
  362. meltygui/view/text_view.py +8031 -0
  363. meltygui/view/texture_view.py +480 -0
  364. meltygui/view/tile_view.py +38 -0
  365. meltygui/view/trace_view.py +923 -0
  366. meltygui/view/voxel_cuda_view.py +824 -0
  367. meltygui/view/voxel_view.py +1860 -0
  368. meltygui/view/window_view.py +111 -0
  369. meltygui-0.1.0.dist-info/METADATA +143 -0
  370. meltygui-0.1.0.dist-info/RECORD +372 -0
  371. meltygui-0.1.0.dist-info/WHEEL +4 -0
  372. meltygui-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,702 @@
1
+ """
2
+ Address + file-watched converter wrappers.
3
+
4
+ Each cache_id gets fully isolated state: its own mtime/size tracking,
5
+ its own cached results, its own original_data for dirty detection.
6
+ No shared state between views.
7
+
8
+ Cross-view change detection happens naturally — each view stats the
9
+ file independently, so one view's write shows up as a disk change
10
+ to the other view on its next load.
11
+
12
+ convert(my_func, dict, registry=Melty, apply=True, cache_id="view_1")
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import difflib
18
+ import functools
19
+ import hashlib
20
+ import sys
21
+ import types
22
+ from dataclasses import dataclass
23
+ import os
24
+ from pathlib import Path
25
+ from typing import Any, Callable
26
+ import inspect
27
+
28
+ from meltygui.core.conversion.path_finder import Pending
29
+ from meltygui.core.rendering.core_decoration import defaults
30
+
31
+
32
+ # ╔══════════════════════════════════════════════════════════════════════════════╗
33
+ # ║ Library write guard ║
34
+ # ╚══════════════════════════════════════════════════════════════════════════════╝
35
+ # The editor resolves a class/function/module to its source FILE and edits a line
36
+ # span in place. Nothing stops `type(value)` from being a class that lives in a
37
+ # third-party package - and a wrong resolve once silently deleted a field from
38
+ # libcst's own whitespace.py, breaking the parser. We are an editor for THIS
39
+ # project's code, never for installed libraries, so every resolve/write checks
40
+ # this gate. Returning False makes resolve_address return None (editor displays
41
+ # "can't resolve") and makes the save functions skip the write - fail closed.
42
+
43
+ # Project root: this file is .../src/lsd/gl_gui/view/core_conversion/address.py
44
+ # → parents[5] is the repo root (the dir that contains `src/`).
45
+ from meltygui.core.runtime.paths import PACKAGE_ROOT
46
+ _PROJECT_ROOT = PACKAGE_ROOT.parent
47
+
48
+ # Every tree whose code the editor may resolve, edit and hotswap: this
49
+ # checkout itself and each meltygui APP's own project (registered by
50
+ # app.glfw_window for the window function's file, and by app.boot for
51
+ # the main module - an app outside this repo is otherwise "library source"
52
+ # to every view codec, so its `@glfw_window(tint=...)` never loads as an
53
+ # input source and a header tint property falls through to the draw_state,
54
+ # 09-12). Resolved paths, first registration defines the order.
55
+ _EDITABLE_ROOTS = ([_PROJECT_ROOT] if (_PROJECT_ROOT / ".git").exists()
56
+ and not {"site-packages", "dist-packages"} & set(_PROJECT_ROOT.parts) else [])
57
+
58
+
59
+ def editable_roots():
60
+ """The registered project roots (resolved Paths), the checkout first."""
61
+ return tuple(_EDITABLE_ROOTS)
62
+
63
+
64
+ def add_editable_root(path) -> Path | None:
65
+ """Register a project tree as editable source — the directory `path`,
66
+ or the PROJECT ROOT of a file `path` (see project_root_of). Idempotent;
67
+ a library install (site-packages, a venv) is never registered. Returns
68
+ the root registered, or None when refused."""
69
+ try:
70
+ p = Path(path).expanduser().resolve()
71
+ except (OSError, ValueError):
72
+ return None
73
+ root = p if p.is_dir() else project_root_of(p)
74
+ if root is None or _LIBRARY_PARTS & set(root.parts):
75
+ return None
76
+ if root not in _EDITABLE_ROOTS:
77
+ _EDITABLE_ROOTS.append(root)
78
+ _EDITABLE_SOURCE_CACHE.clear()
79
+ return root
80
+
81
+
82
+ # Files that mark a project's root when walking up from one of its sources.
83
+ _PROJECT_MARKERS = (".git", "pyproject.toml", "setup.py", "setup.cfg")
84
+
85
+
86
+ def project_root_of(file) -> Path | None:
87
+ """The nearest ancestor of `file` carrying a project marker
88
+ (_PROJECT_MARKERS), else the file's own directory; None when the file
89
+ has no directory."""
90
+ try:
91
+ p = Path(file).resolve()
92
+ except (OSError, ValueError):
93
+ return None
94
+ start = p.parent if not p.is_dir() else p
95
+ for d in (start, *start.parents):
96
+ if any((d / m).exists() for m in _PROJECT_MARKERS):
97
+ return d
98
+ return start
99
+
100
+
101
+ # str(source_file) -> bool. Whether a path is project source never changes
102
+ # within a session, but resolve() walks every path component with an
103
+ # lstat+readlink - ~30 GIO round-trips - and this gate runs per FRAME per code
104
+ # view (resolve_address). Under a CPU-bound background thread each round-trip
105
+ # waits up to a poll interval, so the uncached gate alone stretched frames by
106
+ # hundreds of ms (all-thread sampling 2026-07-31: ~half the render thread's
107
+ # slow samples were in realpath under this call). Bounded and cleared on hotswap.
108
+ _EDITABLE_SOURCE_CACHE = {}
109
+
110
+
111
+ def is_editable_source(source_file) -> bool:
112
+ """True only for source inside a registered project tree (editable_roots:
113
+ this checkout and every meltygui app's own project). Library code
114
+ (site-packages / dist-packages / the venv / the stdlib) is read-only to
115
+ the editor, so we never resolve or write to it."""
116
+ key = str(source_file)
117
+ got = _EDITABLE_SOURCE_CACHE.get(key)
118
+ if got is not None:
119
+ return got
120
+ try:
121
+ p = Path(source_file).resolve()
122
+ except (OSError, ValueError):
123
+ return False
124
+ ok = True
125
+ if {"site-packages", "dist-packages"} & set(p.parts):
126
+ ok = False
127
+ else:
128
+ ok = any(p.is_relative_to(root) for root in _EDITABLE_ROOTS)
129
+ if len(_EDITABLE_SOURCE_CACHE) > 4096:
130
+ _EDITABLE_SOURCE_CACHE.clear()
131
+ _EDITABLE_SOURCE_CACHE[key] = ok
132
+ return ok
133
+
134
+
135
+ _LIBRARY_PARTS = frozenset({"site-packages", "dist-packages", "venv", ".venv",
136
+ "node_modules"})
137
+
138
+
139
+ def writable_file_refusal(path) -> str | None:
140
+ """Why the PLAIN-FILE codecs (TextFileCodec and friends) will not edit
141
+ `path` — a short human reason — or None when they will.
142
+
143
+ The gentler gate: the folder-tree windows mount arbitrary directories and
144
+ apps open whatever they are handed, so whole-file editing is allowed
145
+ anywhere the process may write (a /tmp scratch file, a mounted drive) —
146
+ unlike code codecs, which hotswap live objects and stay pinned to the
147
+ project tree (is_editable_source). Library installs are still refused
148
+ whatever their permissions: a venv is writable too, and writing into
149
+ site-packages through a folder window is the same disaster the strict
150
+ gate exists to prevent. (It used to require $HOME instead of write
151
+ permission, and a refused file showed as a tab that never loaded.)
152
+ A path that does not exist yet is judged by its directory."""
153
+ try:
154
+ p = Path(path).resolve()
155
+ except (OSError, ValueError) as e:
156
+ return f"path cannot be resolved ({e})"
157
+ hit = _LIBRARY_PARTS & set(p.parts)
158
+ if hit:
159
+ return f"inside a library install ({sorted(hit)[0]}/)"
160
+ target = p if p.exists() else p.parent
161
+ if not os.access(target, os.W_OK):
162
+ return "no write permission"
163
+ return None
164
+
165
+
166
+ def is_writable_file(path) -> bool:
167
+ """The plain-file gate as a bool — see writable_file_refusal."""
168
+ return writable_file_refusal(path) is None
169
+
170
+
171
+ # ╔══════════════════════════════════════════════════════════════════════════════╗
172
+ # ║ Address ║
173
+ # ╚══════════════════════════════════════════════════════════════════════════════╝
174
+ ORIGINAL = object() # sentinel for "no original value found"
175
+ APPLY_ALL = object() # sentinel for "apply to all views, not just the one that originated this Pending"
176
+
177
+ @dataclass
178
+ class FileMeta:
179
+
180
+ mtime: float
181
+ size: int
182
+
183
+ def __eq__(self, other):
184
+ if not isinstance(other, FileMeta):
185
+ return NotImplemented
186
+
187
+ return self.mtime == other.mtime and self.size == other.size
188
+
189
+ class Address:
190
+
191
+ # An Address is identified by its LOCATION - (path, start, end) - not its
192
+ # contents. Construction must stay cheap (it runs during resolve_address); hashing
193
+ # the file (a full read + md5 on every construct) was both slow and the wrong
194
+ # identity: two copies of the same span ARE the same address regardless of what
195
+ # the span currently holds. Null defaults so __hash__/__eq__ work on a `self`
196
+ # a live-view stack window captured pre-__init__.
197
+ start = None
198
+ end = None
199
+
200
+ def __init__(self, path, start=None, end=None, source=None, watcher_ds=None):
201
+ self.path = Path(path).resolve()
202
+ self.start = start
203
+ self.end = end
204
+ self.source = source
205
+ self._watcher_ds = watcher_ds
206
+
207
+ def get_meta(self) -> FileMeta:
208
+ s = self.path.stat()
209
+ return FileMeta(mtime=s.st_mtime, size=s.st_size)
210
+
211
+ def __eq__(self, other):
212
+ if not isinstance(other, Address):
213
+ return NotImplemented
214
+ return (self.path == other.path
215
+ and self.start == other.start
216
+ and self.end == other.end)
217
+
218
+ def __hash__(self):
219
+ return hash((self.path, self.start, self.end))
220
+
221
+
222
+ # ╔══════════════════════════════════════════════════════════════════════════════╗
223
+ # ║ Address resolution ║
224
+ # ╚══════════════════════════════════════════════════════════════════════════════╝
225
+ class ValueDict:
226
+ """An if/elif/else block's contents, as a dict subclass.
227
+
228
+ isinstance(c, dict) → True, so iteration/access works normally.
229
+ isinstance(c, Conditional) → True, so the UI can render a
230
+ collapsible conditional block.
231
+
232
+ The .condition attribute holds the full condition text
233
+ (e.g. "if selected", "elif pressed", "else").
234
+ """
235
+
236
+ def __init__(self, cached_value, others=None):
237
+ self.others = others or {}
238
+ self.cached_value = cached_value
239
+
240
+
241
+ import types
242
+ import weakref
243
+
244
+ _cache: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary()
245
+
246
+
247
+ def invalidate_address_cache(obj):
248
+ """Call after hotswapping an object in place."""
249
+ _cache.pop(obj, None)
250
+
251
+
252
+ def update_address_cache(obj, ref: Address):
253
+ """Store a known-correct Address after a recompile.
254
+
255
+ Avoids re-resolving via inspect.getsourcelines, which can return
256
+ wrong line numbers when linecache holds stale file content after
257
+ a hotswap + file rewrite.
258
+ """
259
+ try:
260
+ _cache[obj] = ref
261
+ except TypeError:
262
+ pass
263
+
264
+
265
+ def _unwrap(func):
266
+ while hasattr(func, '__wrapped__'):
267
+ func = func.__wrapped__
268
+ return func
269
+
270
+
271
+ def to_address(value: Any) -> Address:
272
+ """Convert common types to an Address. Unwraps decorated functions."""
273
+ if isinstance(value, Address):
274
+ return value
275
+ if isinstance(value, ValueDict):
276
+ return to_address(value.cached_value)
277
+ if isinstance(value, Path):
278
+ return Address(value)
279
+
280
+ try:
281
+ cached = _cache[value]
282
+ return cached
283
+ except (KeyError, TypeError):
284
+ # TypeError: value isn't weakly referenceable (shouldn't happen
285
+ # for functions/modules/types, but better safe)
286
+ pass
287
+
288
+ result = _resolve(value)
289
+
290
+ try:
291
+ _cache[value] = result
292
+ except TypeError:
293
+ pass
294
+
295
+ return result
296
+
297
+
298
+ def _evict_linecache(filename: str) -> None:
299
+ """Force-evict a file from linecache.
300
+
301
+ linecache.checkcache skips entries with mtime=None (loader-managed),
302
+ so after a hotswap + file rewrite, stale content can persist and
303
+ cause inspect.getsourcelines to return wrong line numbers.
304
+ """
305
+ import linecache
306
+ linecache.cache.pop(filename, None)
307
+
308
+
309
+ def shift_sibling_linenos(saved_source, file_path, after_lineno: int, delta: int,
310
+ include_saved: bool = False) -> None:
311
+ """Shift co_firstlineno of every code object in the same file
312
+ whose first line is > after_lineno, by `delta` lines.
313
+
314
+ Why: inspect.findsource walks backward from co_firstlineno hunting
315
+ for `def`/`class`/`lambda`/`@`. After we expand a function in the
316
+ file, sibling functions below have stale co_firstlineno that lands
317
+ inside the expanded body — findsource then walks back and returns
318
+ the wrong function's source. Patching co_firstlineno keeps every
319
+ other function's line numbers truthful without touching their code.
320
+
321
+ include_saved=True also shifts the saved source itself. Use this for an
322
+ edit ABOVE the saved span (e.g. an inserted import): the saved function's
323
+ own def moved down too, so its co_firstlineno must move with it — otherwise
324
+ the next resolve walks findsource back past the moved def to line 0.
325
+ """
326
+ if delta == 0 or saved_source is None:
327
+ return
328
+
329
+ if isinstance(saved_source, types.ModuleType):
330
+ module = saved_source
331
+ else:
332
+ module_name = getattr(saved_source, "__module__", None)
333
+ module = sys.modules.get(module_name) if module_name else None
334
+ if module is None:
335
+ return
336
+
337
+ try:
338
+ target = Path(file_path).resolve()
339
+ except (OSError, ValueError):
340
+ return
341
+
342
+ def _same_file(code) -> bool:
343
+ try:
344
+ return Path(code.co_filename).resolve() == target
345
+ except (OSError, ValueError):
346
+ return code.co_filename == str(target)
347
+
348
+ # Compare/shift the UNWRAPPED function. A decorated function (@core_func,
349
+ # @window, ...) stored in module/class vars is the WRAPPER: its __code__ lives
350
+ # in the decorator's file (core_render.py), so _same_file(wrapper) is False
351
+ # and it would be skipped - leaving the real function's co_firstlineno stale.
352
+ # function_to_address resolves via inspect.getsourcelines(inspect.unwrap(func)),
353
+ # which keys on the unwrapped co_firstlineno; that's the one that must shift so
354
+ # the next resolve doesn't walk findsource back to the wrong def (or line 0).
355
+ saved_inner = (saved_source if isinstance(saved_source, types.ModuleType)
356
+ else inspect.unwrap(saved_source))
357
+
358
+ def _maybe_shift(func):
359
+ # inspect.unwrap can hand back a non-function (a wrapper method /
360
+ # builtin / slot for an overridden dunder) with no internal __code__. It
361
+ # can also raise on a pathological __wrapped__ chain. EITHER WAY this must
362
+ # not abort the walk: an unhandled error here would skip every function
363
+ # defined BELOW the offending member, leaving their co_firstlineno stale -
364
+ # which is exactly how draw_context_menu kept resolving to the wrong def
365
+ # after draw_collection grew above it.
366
+ try:
367
+ inner = inspect.unwrap(func)
368
+ except Exception:
369
+ return
370
+ if inner is saved_inner and not include_saved:
371
+ return
372
+ code = getattr(inner, "__code__", None)
373
+ if not isinstance(code, types.CodeType):
374
+ return
375
+ if not _same_file(code):
376
+ return
377
+ if code.co_firstlineno > after_lineno:
378
+ try:
379
+ inner.__code__ = code.replace(
380
+ co_firstlineno=code.co_firstlineno + delta)
381
+ except (AttributeError, TypeError):
382
+ # Read-only / non-Python code object - can't shift, but don't
383
+ # let it stop the rest of the walk.
384
+ pass
385
+
386
+ def _walk_class(cls):
387
+ if cls is saved_source and not include_saved:
388
+ return
389
+ for val in list(vars(cls).values()):
390
+ try:
391
+ if isinstance(val, types.FunctionType):
392
+ _maybe_shift(val)
393
+ elif isinstance(val, type):
394
+ _walk_class(val)
395
+ elif isinstance(val, (staticmethod, classmethod)):
396
+ inner = getattr(val, "__func__", None)
397
+ if isinstance(inner, types.FunctionType):
398
+ _maybe_shift(inner)
399
+ except Exception:
400
+ # One bad member must never strand the functions after it.
401
+ continue
402
+
403
+ for val in list(vars(module).values()):
404
+ try:
405
+ if isinstance(val, types.FunctionType):
406
+ _maybe_shift(val)
407
+ elif isinstance(val, type):
408
+ _walk_class(val)
409
+ except Exception:
410
+ continue
411
+
412
+
413
+ def _resolve(value: Any) -> Address | None:
414
+ import inspect, types
415
+ if isinstance(value, types.FunctionType):
416
+ unwrapped = inspect.unwrap(value)
417
+ source_file = inspect.getfile(unwrapped)
418
+ _evict_linecache(source_file)
419
+ source_lines, start_lineno = inspect.getsourcelines(unwrapped)
420
+ return Address(Path(source_file), start_lineno - 1,
421
+ start_lineno - 1 + len(source_lines))
422
+
423
+
424
+ if isinstance(value, types.ModuleType):
425
+ return Address(Path(value.__file__))
426
+
427
+ if isinstance(value, type):
428
+ if value.__module__ in ('builtins', '_collections_abc'):
429
+ return None
430
+ try:
431
+ import inspect
432
+ source_file = inspect.getfile(value)
433
+ _evict_linecache(source_file)
434
+ source_lines, start_lineno = inspect.getsourcelines(value)
435
+ return Address(Path(source_file), start_lineno - 1,
436
+ start_lineno - 1 + len(source_lines))
437
+ except (TypeError, OSError):
438
+ return None
439
+
440
+ return None
441
+ # ╔══════════════════════════════════════════════════════════════════════════════╗
442
+ # ║ Per-cache_id watch state (fully isolated per view) ║
443
+ # ╚══════════════════════════════════════════════════════════════════════════════╝
444
+
445
+ class _WatchState:
446
+ """Everything a single view needs for one converter on one file."""
447
+ __slots__ = ("ref", "mtime", "size", "cached_result",
448
+ "original_output_load", "original_input_load",
449
+ "original_output_save", "original_input_save")
450
+
451
+ def __init__(self, ref: Address):
452
+ self.ref = ref
453
+ self.mtime: float = 0.0
454
+ self.size: int = 0
455
+ self.cached_result: Any = None
456
+ self.original_output_load: Any = None
457
+ self.original_input_load: Any = None
458
+ self.original_output_save: Any = None
459
+ self.original_input_save: Any = None
460
+
461
+ def stat_file(self) -> tuple[float, int]:
462
+ s = self.ref.path.stat()
463
+ return s.st_mtime, s.st_size
464
+
465
+ def is_stale(self) -> bool:
466
+ mtime, size = self.stat_file()
467
+ return mtime != self.mtime or size != self.size
468
+
469
+ def mark_current(self):
470
+ self.mtime, self.size = self.stat_file()
471
+
472
+
473
+ _all_caches: list[dict] = []
474
+
475
+
476
+ def _new_cache() -> dict[str, _WatchState]:
477
+ cache: dict[str, _WatchState] = {}
478
+ _all_caches.append(cache)
479
+ return cache
480
+
481
+
482
+ def clear_watch_cache():
483
+ """Clear all cached state. Useful for tests."""
484
+ for cache in _all_caches:
485
+ cache.clear()
486
+
487
+
488
+ def get_original_value(cache_id: str | None = None,
489
+ ref: Address | None = None,
490
+ of_type: type | None = None) -> Any:
491
+ """Find original_value across all caches.
492
+
493
+ Search by cache_id (exact key) or by ref (scan for matching Address).
494
+ of_type filters the result.
495
+ """
496
+ for cache in _all_caches:
497
+ if cache_id is not None:
498
+ watch = cache.get(cache_id)
499
+ if watch is not None and watch.original_value is not None:
500
+ if of_type is None or isinstance(watch.original_value, of_type):
501
+ return watch.original_value
502
+ if ref is not None:
503
+ for watch in cache.values():
504
+ if watch.ref == ref and watch.original_input_load is not None:
505
+ if of_type is None or isinstance(watch.original_input_load, of_type):
506
+ return watch.original_input_load
507
+ return None
508
+
509
+
510
+ # ╔══════════════════════════════════════════════════════════════════════════════╗
511
+ # ║ Helpers ║
512
+ # ╚══════════════════════════════════════════════════════════════════════════════╝
513
+
514
+ def _accepts_apply(fn: Callable) -> bool:
515
+ import inspect as _inspect
516
+ try:
517
+ return "apply" in _inspect.signature(fn).parameters
518
+ except (ValueError, TypeError):
519
+ return False
520
+
521
+
522
+ def _call_fn(fn, args, apply, *, fn_takes_apply):
523
+ if fn_takes_apply:
524
+ return fn(*args, apply=apply)
525
+ return fn(*args)
526
+
527
+
528
+ def _update_address_for_id(cache_id: str, ref: Address) -> None:
529
+ """Update ref on all watches for a cache_id across all caches.
530
+
531
+ Called after a save that may have changed the line range so every
532
+ converter in the chain uses the correct range on its next reload.
533
+ """
534
+ for cache in _all_caches:
535
+ watch = cache.get(cache_id)
536
+ if watch is not None:
537
+ watch.ref = ref
538
+
539
+
540
+ # ╔══════════════════════════════════════════════════════════════════════════════╗
541
+ # ║ Load wrapper ║
542
+ # ╚══════════════════════════════════════════════════════════════════════════════╝
543
+
544
+ def make_load_wrapper(fn: Callable, load_data: Callable,
545
+ from_type: type) -> Callable:
546
+ """Wrap a forward converter with file watching + caching.
547
+
548
+ cache_id=None → no caching, always load fresh.
549
+ cache_id="x" → cache result per view, detect file changes via mtime/size.
550
+
551
+ First call always loads (no Pending on first visit).
552
+ Subsequent calls return cached result if file unchanged.
553
+ If file changed on disk → Pending(cached_result) until apply=True.
554
+ """
555
+ local_cache = _new_cache()
556
+ fn_takes_apply = _accepts_apply(fn)
557
+
558
+ @functools.wraps(fn)
559
+ def wrapper(value, apply=False, cache_id=None):
560
+
561
+ # ── No cache_id → stateless, always load ──────────────────
562
+ apply_load = apply and apply.__name__ == load_data.__name__ or apply is APPLY_ALL
563
+
564
+ # if apply:
565
+ # if not apply_load:
566
+ # print(
567
+ # f"Converter {fn.__name__} got apply={apply} but will not apply because it did not originate from {value.__name__}")
568
+ # else:
569
+ # print(f"Applying converter {fn.__name__} with apply={apply}")
570
+
571
+ if cache_id is None:
572
+ ref = to_address(value)
573
+ if not apply_load:
574
+ return Pending(originated=load_data, wrapped=None)
575
+ data = load_data(ref)
576
+ return _call_fn(fn, (data, ref), apply, fn_takes_apply=fn_takes_apply)
577
+
578
+ # ── Get or create this view's watch ─────────────────────────
579
+
580
+ watch = local_cache.get(cache_id)
581
+ if watch is None:
582
+ ref = to_address(value)
583
+ watch = _WatchState(ref)
584
+ local_cache[cache_id] = watch
585
+
586
+ if watch is None and not apply_load:
587
+ return Pending(originated=load_data, wrapped=value)
588
+
589
+ watch.original_input_load = value
590
+
591
+ # ── Check this view's own mtime/size ────────────────────────
592
+
593
+ stale = watch.is_stale() if watch is not None else True
594
+
595
+ if not stale and watch.cached_result is not None:
596
+ return watch.cached_result
597
+
598
+ if not apply_load:
599
+ return Pending(originated=load_data, wrapped=watch.cached_result)
600
+
601
+ # ── Reload (uses watch.ref, not re-inspected) ───────────────
602
+
603
+ data = load_data(watch.ref)
604
+ result = _call_fn(fn, (value, data, watch.ref), apply, fn_takes_apply=fn_takes_apply)
605
+ watch.cached_result = result
606
+ watch.original_output_load = data
607
+ watch.mark_current()
608
+ return result
609
+
610
+ wrapper._watched_from_type = from_type
611
+ wrapper._local_cache = local_cache
612
+ wrapper._accepts_apply = True
613
+ wrapper._accepts_cache_id = True
614
+ return wrapper
615
+
616
+
617
+ # ╔══════════════════════════════════════════════════════════════════════════════╗
618
+ # ║ Save wrapper ║
619
+ # ╚══════════════════════════════════════════════════════════════════════════════╝
620
+
621
+ def make_save_wrapper(fn: Callable, save_data: Callable,
622
+ to_type: type,
623
+ load_cache: dict | None = None) -> Callable:
624
+ """Wrap an inverse converter with dirty detection + save.
625
+
626
+ cache_id=None → stateless pass-through.
627
+ cache_id="x" → dirty detect against original_data from the paired load.
628
+
629
+ Returns watch.original_value (the original input to the forward
630
+ converter, e.g. the function reference) on all paths.
631
+ """
632
+ fn_takes_apply = _accepts_apply(fn)
633
+
634
+ @functools.wraps(fn)
635
+ def wrapper(converter_input, apply=False, cache_id=None):
636
+
637
+ apply_save = apply and apply.__name__ == save_data.__name__ or apply is APPLY_ALL
638
+
639
+ # if apply:
640
+ # if not apply_save:
641
+ # print(f"Converter {fn.__name__} got apply={apply} but will not apply because it did not originate from {save_data.__name__}")
642
+ # else:
643
+ # print(f"Applying converter {fn.__name__} with apply={apply}")
644
+
645
+ # ── No cache_id → stateless pass-through ────────────────────
646
+
647
+ if cache_id is None:
648
+ return _call_fn(fn, (converter_input,), apply, fn_takes_apply=fn_takes_apply)
649
+
650
+ # ── Find this view's paired load watch ──────────────────────
651
+ watch: _WatchState = load_cache.get(cache_id) if load_cache else None
652
+ if watch is None:
653
+ return converter_input # never loaded, nothing to save
654
+
655
+ # ── Extract data from converter function ─────────────────────
656
+
657
+ converter_output = _call_fn(fn, (converter_input,), apply, fn_takes_apply=fn_takes_apply)
658
+
659
+ # ── Dirty detection ─────────────────────────────────────────
660
+
661
+ is_dirty = (watch.original_output_load is not None
662
+ and converter_output != watch.original_output_load)
663
+
664
+ if not is_dirty:
665
+
666
+ return watch.original_input_load
667
+
668
+ if not apply_save:
669
+ status = "dirty"
670
+ try:
671
+ lines1 = str(watch.original_output_load).splitlines(keepends=True)
672
+ lines2 = str(converter_output).splitlines(keepends=True)
673
+
674
+ # Unified diff (like `git diff`)
675
+ diff = difflib.unified_diff(lines1, lines2, fromfile="original.py", tofile="modified.py")
676
+ # print("".join(diff))
677
+ status = "".join(diff)
678
+ except Exception as e:
679
+ print(f"Diff failed: {e}")
680
+
681
+ return Pending(originated=save_data, wrapped=converter_output, status=status)
682
+
683
+ # ── Save ────────────────────────────────────────────────────
684
+
685
+ ref = watch.ref
686
+ updated_ref = save_data(converter_input, ref, converter_output, watch)
687
+ if isinstance(updated_ref, Pending):
688
+ return updated_ref
689
+ if isinstance(updated_ref, Address):
690
+ ref = updated_ref
691
+
692
+ watch.original_output_load = converter_output
693
+ watch.ref = ref
694
+ watch.mark_current()
695
+ _update_address_for_id(cache_id, ref)
696
+
697
+ return watch.original_input_load
698
+
699
+ wrapper._watched_to_type = to_type
700
+ wrapper._accepts_apply = True
701
+ wrapper._accepts_cache_id = True
702
+ return wrapper