MoleditPy-linux 2.4.1__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.
- moleditpy_linux/__init__.py +17 -0
- moleditpy_linux/__main__.py +29 -0
- moleditpy_linux/main.py +37 -0
- moleditpy_linux/modules/__init__.py +41 -0
- moleditpy_linux/modules/about_dialog.py +104 -0
- moleditpy_linux/modules/align_plane_dialog.py +292 -0
- moleditpy_linux/modules/alignment_dialog.py +272 -0
- moleditpy_linux/modules/analysis_window.py +209 -0
- moleditpy_linux/modules/angle_dialog.py +440 -0
- moleditpy_linux/modules/assets/file_icon.ico +0 -0
- moleditpy_linux/modules/assets/icon.icns +0 -0
- moleditpy_linux/modules/assets/icon.ico +0 -0
- moleditpy_linux/modules/assets/icon.png +0 -0
- moleditpy_linux/modules/atom_item.py +395 -0
- moleditpy_linux/modules/bond_item.py +464 -0
- moleditpy_linux/modules/bond_length_dialog.py +380 -0
- moleditpy_linux/modules/calculation_worker.py +766 -0
- moleditpy_linux/modules/color_settings_dialog.py +321 -0
- moleditpy_linux/modules/constants.py +88 -0
- moleditpy_linux/modules/constrained_optimization_dialog.py +678 -0
- moleditpy_linux/modules/custom_interactor_style.py +749 -0
- moleditpy_linux/modules/custom_qt_interactor.py +102 -0
- moleditpy_linux/modules/dialog3_d_picking_mixin.py +141 -0
- moleditpy_linux/modules/dihedral_dialog.py +443 -0
- moleditpy_linux/modules/main_window.py +850 -0
- moleditpy_linux/modules/main_window_app_state.py +787 -0
- moleditpy_linux/modules/main_window_compute.py +1242 -0
- moleditpy_linux/modules/main_window_dialog_manager.py +460 -0
- moleditpy_linux/modules/main_window_edit_3d.py +536 -0
- moleditpy_linux/modules/main_window_edit_actions.py +1565 -0
- moleditpy_linux/modules/main_window_export.py +917 -0
- moleditpy_linux/modules/main_window_main_init.py +2100 -0
- moleditpy_linux/modules/main_window_molecular_parsers.py +1044 -0
- moleditpy_linux/modules/main_window_project_io.py +434 -0
- moleditpy_linux/modules/main_window_string_importers.py +275 -0
- moleditpy_linux/modules/main_window_ui_manager.py +602 -0
- moleditpy_linux/modules/main_window_view_3d.py +1539 -0
- moleditpy_linux/modules/main_window_view_loaders.py +355 -0
- moleditpy_linux/modules/mirror_dialog.py +122 -0
- moleditpy_linux/modules/molecular_data.py +302 -0
- moleditpy_linux/modules/molecule_scene.py +2000 -0
- moleditpy_linux/modules/move_group_dialog.py +600 -0
- moleditpy_linux/modules/periodic_table_dialog.py +84 -0
- moleditpy_linux/modules/planarize_dialog.py +220 -0
- moleditpy_linux/modules/plugin_interface.py +215 -0
- moleditpy_linux/modules/plugin_manager.py +473 -0
- moleditpy_linux/modules/plugin_manager_window.py +274 -0
- moleditpy_linux/modules/settings_dialog.py +1503 -0
- moleditpy_linux/modules/template_preview_item.py +157 -0
- moleditpy_linux/modules/template_preview_view.py +74 -0
- moleditpy_linux/modules/translation_dialog.py +364 -0
- moleditpy_linux/modules/user_template_dialog.py +692 -0
- moleditpy_linux/modules/zoomable_view.py +129 -0
- moleditpy_linux-2.4.1.dist-info/METADATA +954 -0
- moleditpy_linux-2.4.1.dist-info/RECORD +59 -0
- moleditpy_linux-2.4.1.dist-info/WHEEL +5 -0
- moleditpy_linux-2.4.1.dist-info/entry_points.txt +2 -0
- moleditpy_linux-2.4.1.dist-info/licenses/LICENSE +674 -0
- moleditpy_linux-2.4.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
"""
|
|
5
|
+
MoleditPy — A Python-based molecular editing software
|
|
6
|
+
|
|
7
|
+
Author: Hiromichi Yokoyama
|
|
8
|
+
License: GPL-3.0 license
|
|
9
|
+
Repo: https://github.com/HiroYokoyama/python_molecular_editor
|
|
10
|
+
DOI: 10.5281/zenodo.17268532
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
"""
|
|
14
|
+
plugin_manager.py
|
|
15
|
+
Manages discovery, loading, and execution of external plugins.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
import shutil
|
|
21
|
+
import zipfile
|
|
22
|
+
import importlib.util
|
|
23
|
+
import traceback
|
|
24
|
+
import ast
|
|
25
|
+
from PyQt6.QtGui import QDesktopServices
|
|
26
|
+
from PyQt6.QtCore import QUrl
|
|
27
|
+
from PyQt6.QtWidgets import QMessageBox
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
from .plugin_interface import PluginContext
|
|
31
|
+
except ImportError:
|
|
32
|
+
# Fallback if running as script
|
|
33
|
+
from modules.plugin_interface import PluginContext
|
|
34
|
+
|
|
35
|
+
class PluginManager:
|
|
36
|
+
def __init__(self, main_window=None):
|
|
37
|
+
self.plugin_dir = os.path.join(os.path.expanduser('~'), '.moleditpy', 'plugins')
|
|
38
|
+
self.plugins = [] # List of dicts
|
|
39
|
+
self.main_window = main_window
|
|
40
|
+
|
|
41
|
+
# Registries for actions
|
|
42
|
+
self.menu_actions = [] # List of (plugin_name, path, callback, text, icon, shortcut)
|
|
43
|
+
self.toolbar_actions = []
|
|
44
|
+
self.drop_handlers = [] # List of (priority, plugin_name, callback)
|
|
45
|
+
|
|
46
|
+
# Extended Registries (Added to prevent lazy initialization "monkey patching")
|
|
47
|
+
self.export_actions = []
|
|
48
|
+
self.optimization_methods = {}
|
|
49
|
+
self.file_openers = {} # ext -> list of {'plugin':..., 'callback':..., 'priority':...}
|
|
50
|
+
self.analysis_tools = []
|
|
51
|
+
self.save_handlers = {}
|
|
52
|
+
self.load_handlers = {}
|
|
53
|
+
self.custom_3d_styles = {} # style_name -> {'plugin': name, 'callback': func}
|
|
54
|
+
self.document_reset_handlers = [] # List of callbacks to call on new document
|
|
55
|
+
|
|
56
|
+
def get_main_window(self):
|
|
57
|
+
return self.main_window
|
|
58
|
+
|
|
59
|
+
def set_main_window(self, mw):
|
|
60
|
+
self.main_window = mw
|
|
61
|
+
|
|
62
|
+
def ensure_plugin_dir(self):
|
|
63
|
+
"""Creates the plugin directory if it doesn't exist."""
|
|
64
|
+
if not os.path.exists(self.plugin_dir):
|
|
65
|
+
try:
|
|
66
|
+
os.makedirs(self.plugin_dir)
|
|
67
|
+
except OSError as e:
|
|
68
|
+
print(f"Error creating plugin directory: {e}")
|
|
69
|
+
|
|
70
|
+
def open_plugin_folder(self):
|
|
71
|
+
"""Opens the plugin directory in the OS file explorer."""
|
|
72
|
+
self.ensure_plugin_dir()
|
|
73
|
+
QDesktopServices.openUrl(QUrl.fromLocalFile(self.plugin_dir))
|
|
74
|
+
|
|
75
|
+
def install_plugin(self, file_path):
|
|
76
|
+
"""Copies a plugin file to the plugin directory. Supports .py and .zip."""
|
|
77
|
+
self.ensure_plugin_dir()
|
|
78
|
+
try:
|
|
79
|
+
# Handle trailing slash and normalize path
|
|
80
|
+
file_path = os.path.normpath(file_path)
|
|
81
|
+
filename = os.path.basename(file_path)
|
|
82
|
+
|
|
83
|
+
if os.path.isdir(file_path):
|
|
84
|
+
# Copy entire directory
|
|
85
|
+
dest_path = os.path.join(self.plugin_dir, filename)
|
|
86
|
+
if os.path.exists(dest_path):
|
|
87
|
+
# Option 1: Overwrite (remove then copy) - safer for clean install
|
|
88
|
+
if os.path.isdir(dest_path):
|
|
89
|
+
shutil.rmtree(dest_path)
|
|
90
|
+
else:
|
|
91
|
+
os.remove(dest_path)
|
|
92
|
+
|
|
93
|
+
# Copy directory, ignoring cache files
|
|
94
|
+
shutil.copytree(file_path, dest_path, ignore=shutil.ignore_patterns('__pycache__', '*.pyc', '.git'))
|
|
95
|
+
msg = f"Installed package {filename}"
|
|
96
|
+
elif filename.lower().endswith('.zip'):
|
|
97
|
+
# Extract ZIP contents
|
|
98
|
+
with zipfile.ZipFile(file_path, 'r') as zf:
|
|
99
|
+
# Smart Extraction: Check if ZIP has a single top-level folder
|
|
100
|
+
# Fix for paths with backslashes on Windows if zip was created on Windows
|
|
101
|
+
roots = set()
|
|
102
|
+
for name in zf.namelist():
|
|
103
|
+
# Normalize path separators to forward slash for consistent check
|
|
104
|
+
name = name.replace('\\', '/')
|
|
105
|
+
parts = name.split('/')
|
|
106
|
+
if parts[0]:
|
|
107
|
+
roots.add(parts[0])
|
|
108
|
+
|
|
109
|
+
is_nested = (len(roots) == 1)
|
|
110
|
+
|
|
111
|
+
if is_nested:
|
|
112
|
+
# Case A: ZIP contains a single folder (e.g. MyPlugin/init.py)
|
|
113
|
+
top_folder = list(roots)[0]
|
|
114
|
+
|
|
115
|
+
# Guard: If the single item is __init__.py, we MUST create a wrapper folder
|
|
116
|
+
# otherwise we pollute the plugin_dir root.
|
|
117
|
+
if top_folder == "__init__.py":
|
|
118
|
+
is_nested = False
|
|
119
|
+
|
|
120
|
+
if is_nested:
|
|
121
|
+
# Case A (Confirmed): Extract directly
|
|
122
|
+
dest_path = os.path.join(self.plugin_dir, top_folder)
|
|
123
|
+
|
|
124
|
+
# Clean Install: Remove existing folder to prevent stale files
|
|
125
|
+
if os.path.exists(dest_path):
|
|
126
|
+
if os.path.isdir(dest_path):
|
|
127
|
+
shutil.rmtree(dest_path)
|
|
128
|
+
else:
|
|
129
|
+
os.remove(dest_path)
|
|
130
|
+
|
|
131
|
+
zf.extractall(self.plugin_dir)
|
|
132
|
+
msg = f"Installed package {top_folder} (from ZIP)"
|
|
133
|
+
else:
|
|
134
|
+
# Case B: ZIP is flat (e.g. file1.py, file2.py or just __init__.py)
|
|
135
|
+
# Extract into a new folder named after the ZIP file
|
|
136
|
+
folder_name = os.path.splitext(filename)[0]
|
|
137
|
+
dest_path = os.path.join(self.plugin_dir, folder_name)
|
|
138
|
+
|
|
139
|
+
if os.path.exists(dest_path):
|
|
140
|
+
if os.path.isdir(dest_path):
|
|
141
|
+
shutil.rmtree(dest_path)
|
|
142
|
+
else:
|
|
143
|
+
os.remove(dest_path)
|
|
144
|
+
|
|
145
|
+
os.makedirs(dest_path)
|
|
146
|
+
zf.extractall(dest_path)
|
|
147
|
+
msg = f"Installed package {folder_name} (from Flat ZIP)"
|
|
148
|
+
else:
|
|
149
|
+
# Standard file copy
|
|
150
|
+
dest_path = os.path.join(self.plugin_dir, filename)
|
|
151
|
+
if os.path.exists(dest_path):
|
|
152
|
+
if os.path.isdir(dest_path):
|
|
153
|
+
shutil.rmtree(dest_path)
|
|
154
|
+
shutil.copy2(file_path, dest_path)
|
|
155
|
+
msg = f"Installed {filename}"
|
|
156
|
+
|
|
157
|
+
# Reload plugins after install
|
|
158
|
+
if self.main_window:
|
|
159
|
+
self.discover_plugins(self.main_window)
|
|
160
|
+
return True, msg
|
|
161
|
+
except Exception as e:
|
|
162
|
+
return False, str(e)
|
|
163
|
+
|
|
164
|
+
def discover_plugins(self, parent=None):
|
|
165
|
+
"""
|
|
166
|
+
Hybrid discovery:
|
|
167
|
+
- Folders with '__init__.py' -> Treated as single package plugin.
|
|
168
|
+
- Folders without '__init__.py' -> Treated as category folders (scan for .py inside).
|
|
169
|
+
"""
|
|
170
|
+
if parent:
|
|
171
|
+
self.main_window = parent
|
|
172
|
+
|
|
173
|
+
self.ensure_plugin_dir()
|
|
174
|
+
self.plugins = []
|
|
175
|
+
# Clear registries
|
|
176
|
+
self.menu_actions = []
|
|
177
|
+
self.toolbar_actions = []
|
|
178
|
+
self.drop_handlers = []
|
|
179
|
+
self.export_actions = []
|
|
180
|
+
self.optimization_methods = {}
|
|
181
|
+
self.file_openers = {}
|
|
182
|
+
self.analysis_tools = []
|
|
183
|
+
self.save_handlers = {}
|
|
184
|
+
self.load_handlers = {}
|
|
185
|
+
self.custom_3d_styles = {}
|
|
186
|
+
self.document_reset_handlers = []
|
|
187
|
+
|
|
188
|
+
if not os.path.exists(self.plugin_dir):
|
|
189
|
+
return []
|
|
190
|
+
|
|
191
|
+
for root, dirs, files in os.walk(self.plugin_dir):
|
|
192
|
+
# Exclude hidden directories
|
|
193
|
+
dirs[:] = [d for d in dirs if not d.startswith('__') and d != '__pycache__']
|
|
194
|
+
|
|
195
|
+
# [Check] Is current dir a package (plugin body)?
|
|
196
|
+
if "__init__.py" in files:
|
|
197
|
+
# === Case 1: Package Plugin (Folder is the plugin) ===
|
|
198
|
+
|
|
199
|
+
# Stop recursion into this folder
|
|
200
|
+
dirs[:] = []
|
|
201
|
+
|
|
202
|
+
entry_point = os.path.join(root, "__init__.py")
|
|
203
|
+
# Category is relative path to parent folder
|
|
204
|
+
rel_path = os.path.relpath(os.path.dirname(root), self.plugin_dir)
|
|
205
|
+
category = rel_path if rel_path != "." else ""
|
|
206
|
+
|
|
207
|
+
# Module name is the folder name
|
|
208
|
+
module_name = os.path.basename(root)
|
|
209
|
+
|
|
210
|
+
self._load_single_plugin(entry_point, module_name, category)
|
|
211
|
+
|
|
212
|
+
else:
|
|
213
|
+
# === Case 2: Category Folder (Load individual .py files) ===
|
|
214
|
+
|
|
215
|
+
# Category is relative path to current folder
|
|
216
|
+
rel_path = os.path.relpath(root, self.plugin_dir)
|
|
217
|
+
category = rel_path if rel_path != "." else ""
|
|
218
|
+
|
|
219
|
+
for filename in files:
|
|
220
|
+
if filename.endswith(".py") and not filename.startswith("__"):
|
|
221
|
+
entry_point = os.path.join(root, filename)
|
|
222
|
+
module_name = os.path.splitext(filename)[0]
|
|
223
|
+
|
|
224
|
+
self._load_single_plugin(entry_point, module_name, category)
|
|
225
|
+
|
|
226
|
+
return self.plugins
|
|
227
|
+
|
|
228
|
+
def _load_single_plugin(self, filepath, module_name, category):
|
|
229
|
+
"""Common loading logic for both single-file and package plugins."""
|
|
230
|
+
try:
|
|
231
|
+
# Ensure unique module name by including category path
|
|
232
|
+
# e.g. Analysis.Docking
|
|
233
|
+
unique_module_name = f"{category.replace(os.sep, '.')}.{module_name}" if category else module_name
|
|
234
|
+
unique_module_name = unique_module_name.strip(".")
|
|
235
|
+
|
|
236
|
+
spec = importlib.util.spec_from_file_location(unique_module_name, filepath)
|
|
237
|
+
if spec and spec.loader:
|
|
238
|
+
module = importlib.util.module_from_spec(spec)
|
|
239
|
+
sys.modules[spec.name] = module
|
|
240
|
+
|
|
241
|
+
# Inject category info
|
|
242
|
+
module.PLUGIN_CATEGORY = category
|
|
243
|
+
|
|
244
|
+
spec.loader.exec_module(module)
|
|
245
|
+
|
|
246
|
+
# --- Metadata Extraction ---
|
|
247
|
+
# Metadata
|
|
248
|
+
# Priority: PLUGIN_XXX > __xxx__ > Fallback
|
|
249
|
+
plugin_name = getattr(module, 'PLUGIN_NAME', module_name)
|
|
250
|
+
plugin_version = getattr(module, 'PLUGIN_VERSION', getattr(module, '__version__', 'Unknown'))
|
|
251
|
+
plugin_author = getattr(module, 'PLUGIN_AUTHOR', getattr(module, '__author__', 'Unknown'))
|
|
252
|
+
plugin_desc = getattr(module, 'PLUGIN_DESCRIPTION', getattr(module, '__doc__', ''))
|
|
253
|
+
plugin_category = getattr(module, 'PLUGIN_CATEGORY', category)
|
|
254
|
+
|
|
255
|
+
# Additional cleanup for docstring (strip whitespace)
|
|
256
|
+
if plugin_desc is None: plugin_desc = ""
|
|
257
|
+
plugin_desc = str(plugin_desc).strip()
|
|
258
|
+
|
|
259
|
+
# Handle version tuple
|
|
260
|
+
if isinstance(plugin_version, tuple):
|
|
261
|
+
plugin_version = ".".join(map(str, plugin_version))
|
|
262
|
+
|
|
263
|
+
# Interface compliance
|
|
264
|
+
has_run = hasattr(module, 'run') and callable(module.run)
|
|
265
|
+
has_autorun = hasattr(module, 'autorun') and callable(module.autorun)
|
|
266
|
+
has_init = hasattr(module, 'initialize') and callable(module.initialize)
|
|
267
|
+
|
|
268
|
+
status = "Loaded"
|
|
269
|
+
|
|
270
|
+
# Execute initialization
|
|
271
|
+
if has_init:
|
|
272
|
+
context = PluginContext(self, plugin_name)
|
|
273
|
+
# Pass category to context if needed, currently not storing it in context directly but could be useful
|
|
274
|
+
try:
|
|
275
|
+
module.initialize(context)
|
|
276
|
+
except Exception as e:
|
|
277
|
+
status = f"Error (Init): {e}"
|
|
278
|
+
print(f"Plugin {plugin_name} initialize error: {e}")
|
|
279
|
+
traceback.print_exc()
|
|
280
|
+
elif has_autorun:
|
|
281
|
+
try:
|
|
282
|
+
if self.main_window:
|
|
283
|
+
module.autorun(self.main_window)
|
|
284
|
+
else:
|
|
285
|
+
status = "Skipped (No MW)"
|
|
286
|
+
except Exception as e:
|
|
287
|
+
status = f"Error (Autorun): {e}"
|
|
288
|
+
print(f"Plugin {plugin_name} autorun error: {e}")
|
|
289
|
+
traceback.print_exc()
|
|
290
|
+
elif not has_run:
|
|
291
|
+
status = "No Entry Point"
|
|
292
|
+
|
|
293
|
+
self.plugins.append({
|
|
294
|
+
'name': plugin_name,
|
|
295
|
+
'version': plugin_version,
|
|
296
|
+
'author': plugin_author,
|
|
297
|
+
'description': plugin_desc,
|
|
298
|
+
'module': module,
|
|
299
|
+
'category': plugin_category, # Store category
|
|
300
|
+
'status': status,
|
|
301
|
+
'filepath': filepath,
|
|
302
|
+
'has_run': has_run
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
except Exception as e:
|
|
306
|
+
print(f"Failed to load plugin {module_name}: {e}")
|
|
307
|
+
traceback.print_exc()
|
|
308
|
+
|
|
309
|
+
def run_plugin(self, module, main_window):
|
|
310
|
+
"""Executes the plugin's run method (Legacy manual trigger)."""
|
|
311
|
+
try:
|
|
312
|
+
module.run(main_window)
|
|
313
|
+
except Exception as e:
|
|
314
|
+
QMessageBox.critical(main_window, "Plugin Error", f"Error running plugin '{getattr(module, 'PLUGIN_NAME', 'Unknown')}':\n{e}")
|
|
315
|
+
traceback.print_exc()
|
|
316
|
+
|
|
317
|
+
# --- Registration Callbacks ---
|
|
318
|
+
def register_menu_action(self, plugin_name, path, callback, text, icon, shortcut):
|
|
319
|
+
self.menu_actions.append({
|
|
320
|
+
'plugin': plugin_name, 'path': path, 'callback': callback,
|
|
321
|
+
'text': text, 'icon': icon, 'shortcut': shortcut
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
def register_toolbar_action(self, plugin_name, callback, text, icon, tooltip):
|
|
325
|
+
self.toolbar_actions.append({
|
|
326
|
+
'plugin': plugin_name, 'callback': callback,
|
|
327
|
+
'text': text, 'icon': icon, 'tooltip': tooltip
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def register_drop_handler(self, plugin_name, callback, priority):
|
|
333
|
+
self.drop_handlers.append({
|
|
334
|
+
'priority': priority, 'plugin': plugin_name, 'callback': callback
|
|
335
|
+
})
|
|
336
|
+
# Sort by priority desc
|
|
337
|
+
self.drop_handlers.sort(key=lambda x: x['priority'], reverse=True)
|
|
338
|
+
|
|
339
|
+
def register_export_action(self, plugin_name, label, callback):
|
|
340
|
+
self.export_actions.append({
|
|
341
|
+
'plugin': plugin_name, 'label': label, 'callback': callback
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
def register_optimization_method(self, plugin_name, method_name, callback):
|
|
345
|
+
# Key by upper-case method name for consistency
|
|
346
|
+
self.optimization_methods[method_name.upper()] = {
|
|
347
|
+
'plugin': plugin_name, 'callback': callback, 'label': method_name
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
def register_file_opener(self, plugin_name, extension, callback, priority=0):
|
|
351
|
+
# Normalize extension to lowercase
|
|
352
|
+
ext = extension.lower()
|
|
353
|
+
if not ext.startswith('.'):
|
|
354
|
+
ext = '.' + ext
|
|
355
|
+
|
|
356
|
+
if ext not in self.file_openers:
|
|
357
|
+
self.file_openers[ext] = []
|
|
358
|
+
|
|
359
|
+
self.file_openers[ext].append({
|
|
360
|
+
'plugin': plugin_name,
|
|
361
|
+
'callback': callback,
|
|
362
|
+
'priority': priority
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
# Sort by priority descending
|
|
366
|
+
self.file_openers[ext].sort(key=lambda x: x['priority'], reverse=True)
|
|
367
|
+
|
|
368
|
+
# Analysis Tools registration
|
|
369
|
+
def register_analysis_tool(self, plugin_name, label, callback):
|
|
370
|
+
self.analysis_tools.append({'plugin': plugin_name, 'label': label, 'callback': callback})
|
|
371
|
+
|
|
372
|
+
# State Persistence registration
|
|
373
|
+
def register_save_handler(self, plugin_name, callback):
|
|
374
|
+
self.save_handlers[plugin_name] = callback
|
|
375
|
+
|
|
376
|
+
def register_load_handler(self, plugin_name, callback):
|
|
377
|
+
self.load_handlers[plugin_name] = callback
|
|
378
|
+
|
|
379
|
+
def register_3d_style(self, plugin_name, style_name, callback):
|
|
380
|
+
self.custom_3d_styles[style_name] = {
|
|
381
|
+
'plugin': plugin_name, 'callback': callback
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
def register_document_reset_handler(self, plugin_name, callback):
|
|
385
|
+
"""Register callback to be invoked when a new document is created."""
|
|
386
|
+
self.document_reset_handlers.append({
|
|
387
|
+
'plugin': plugin_name,
|
|
388
|
+
'callback': callback
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
def invoke_document_reset_handlers(self):
|
|
392
|
+
"""Call all registered document reset handlers."""
|
|
393
|
+
for handler in self.document_reset_handlers:
|
|
394
|
+
try:
|
|
395
|
+
handler['callback']()
|
|
396
|
+
except Exception as e:
|
|
397
|
+
print(f"Error in document reset handler for {handler['plugin']}: {e}")
|
|
398
|
+
|
|
399
|
+
def get_plugin_info_safe(self, file_path):
|
|
400
|
+
"""Extracts plugin metadata using AST parsing (safe, no execution)."""
|
|
401
|
+
info = {
|
|
402
|
+
'name': os.path.basename(file_path),
|
|
403
|
+
'version': 'Unknown',
|
|
404
|
+
'author': 'Unknown',
|
|
405
|
+
'description': ''
|
|
406
|
+
}
|
|
407
|
+
try:
|
|
408
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
409
|
+
tree = ast.parse(f.read())
|
|
410
|
+
|
|
411
|
+
for node in tree.body:
|
|
412
|
+
targets = []
|
|
413
|
+
if isinstance(node, ast.Assign):
|
|
414
|
+
targets = node.targets
|
|
415
|
+
elif isinstance(node, ast.AnnAssign):
|
|
416
|
+
targets = [node.target]
|
|
417
|
+
|
|
418
|
+
for target in targets:
|
|
419
|
+
if isinstance(target, ast.Name):
|
|
420
|
+
# Helper to extract value
|
|
421
|
+
val = None
|
|
422
|
+
if node.value: # AnnAssign might presumably not have value? (though usually does for module globals)
|
|
423
|
+
if isinstance(node.value, ast.Constant): # Py3.8+
|
|
424
|
+
val = node.value.value
|
|
425
|
+
elif hasattr(ast, 'Str') and isinstance(node.value, ast.Str): # Py3.7 and below
|
|
426
|
+
val = node.value.s
|
|
427
|
+
elif isinstance(node.value, ast.Tuple):
|
|
428
|
+
# Handle version tuples e.g. (1, 0, 0)
|
|
429
|
+
try:
|
|
430
|
+
# Extract simple constants from tuple
|
|
431
|
+
elts = []
|
|
432
|
+
for elt in node.value.elts:
|
|
433
|
+
if isinstance(elt, ast.Constant):
|
|
434
|
+
elts.append(elt.value)
|
|
435
|
+
elif hasattr(ast, 'Num') and isinstance(elt, ast.Num):
|
|
436
|
+
elts.append(elt.n)
|
|
437
|
+
val = ".".join(map(str, elts))
|
|
438
|
+
except:
|
|
439
|
+
pass
|
|
440
|
+
|
|
441
|
+
if val is not None:
|
|
442
|
+
if target.id == 'PLUGIN_NAME':
|
|
443
|
+
info['name'] = val
|
|
444
|
+
elif target.id == 'PLUGIN_VERSION':
|
|
445
|
+
info['version'] = val
|
|
446
|
+
elif target.id == 'PLUGIN_AUTHOR':
|
|
447
|
+
info['author'] = val
|
|
448
|
+
elif target.id == 'PLUGIN_DESCRIPTION':
|
|
449
|
+
info['description'] = val
|
|
450
|
+
elif target.id == 'PLUGIN_CATEGORY':
|
|
451
|
+
info['category'] = val
|
|
452
|
+
elif target.id == '__version__' and info['version'] == 'Unknown':
|
|
453
|
+
info['version'] = val
|
|
454
|
+
elif target.id == '__author__' and info['author'] == 'Unknown':
|
|
455
|
+
info['author'] = val
|
|
456
|
+
|
|
457
|
+
# Docstring extraction
|
|
458
|
+
if isinstance(node, ast.Expr):
|
|
459
|
+
val = None
|
|
460
|
+
if isinstance(node.value, ast.Constant) and isinstance(node.value.value, str):
|
|
461
|
+
val = node.value.value
|
|
462
|
+
elif hasattr(ast, 'Str') and isinstance(node.value, ast.Str):
|
|
463
|
+
val = node.value.s
|
|
464
|
+
|
|
465
|
+
if val and not info['description']:
|
|
466
|
+
info['description'] = val.strip().split('\n')[0]
|
|
467
|
+
|
|
468
|
+
except Exception as e:
|
|
469
|
+
print(f"Error parsing plugin info: {e}")
|
|
470
|
+
return info
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
|