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.
Files changed (59) hide show
  1. moleditpy_linux/__init__.py +17 -0
  2. moleditpy_linux/__main__.py +29 -0
  3. moleditpy_linux/main.py +37 -0
  4. moleditpy_linux/modules/__init__.py +41 -0
  5. moleditpy_linux/modules/about_dialog.py +104 -0
  6. moleditpy_linux/modules/align_plane_dialog.py +292 -0
  7. moleditpy_linux/modules/alignment_dialog.py +272 -0
  8. moleditpy_linux/modules/analysis_window.py +209 -0
  9. moleditpy_linux/modules/angle_dialog.py +440 -0
  10. moleditpy_linux/modules/assets/file_icon.ico +0 -0
  11. moleditpy_linux/modules/assets/icon.icns +0 -0
  12. moleditpy_linux/modules/assets/icon.ico +0 -0
  13. moleditpy_linux/modules/assets/icon.png +0 -0
  14. moleditpy_linux/modules/atom_item.py +395 -0
  15. moleditpy_linux/modules/bond_item.py +464 -0
  16. moleditpy_linux/modules/bond_length_dialog.py +380 -0
  17. moleditpy_linux/modules/calculation_worker.py +766 -0
  18. moleditpy_linux/modules/color_settings_dialog.py +321 -0
  19. moleditpy_linux/modules/constants.py +88 -0
  20. moleditpy_linux/modules/constrained_optimization_dialog.py +678 -0
  21. moleditpy_linux/modules/custom_interactor_style.py +749 -0
  22. moleditpy_linux/modules/custom_qt_interactor.py +102 -0
  23. moleditpy_linux/modules/dialog3_d_picking_mixin.py +141 -0
  24. moleditpy_linux/modules/dihedral_dialog.py +443 -0
  25. moleditpy_linux/modules/main_window.py +850 -0
  26. moleditpy_linux/modules/main_window_app_state.py +787 -0
  27. moleditpy_linux/modules/main_window_compute.py +1242 -0
  28. moleditpy_linux/modules/main_window_dialog_manager.py +460 -0
  29. moleditpy_linux/modules/main_window_edit_3d.py +536 -0
  30. moleditpy_linux/modules/main_window_edit_actions.py +1565 -0
  31. moleditpy_linux/modules/main_window_export.py +917 -0
  32. moleditpy_linux/modules/main_window_main_init.py +2100 -0
  33. moleditpy_linux/modules/main_window_molecular_parsers.py +1044 -0
  34. moleditpy_linux/modules/main_window_project_io.py +434 -0
  35. moleditpy_linux/modules/main_window_string_importers.py +275 -0
  36. moleditpy_linux/modules/main_window_ui_manager.py +602 -0
  37. moleditpy_linux/modules/main_window_view_3d.py +1539 -0
  38. moleditpy_linux/modules/main_window_view_loaders.py +355 -0
  39. moleditpy_linux/modules/mirror_dialog.py +122 -0
  40. moleditpy_linux/modules/molecular_data.py +302 -0
  41. moleditpy_linux/modules/molecule_scene.py +2000 -0
  42. moleditpy_linux/modules/move_group_dialog.py +600 -0
  43. moleditpy_linux/modules/periodic_table_dialog.py +84 -0
  44. moleditpy_linux/modules/planarize_dialog.py +220 -0
  45. moleditpy_linux/modules/plugin_interface.py +215 -0
  46. moleditpy_linux/modules/plugin_manager.py +473 -0
  47. moleditpy_linux/modules/plugin_manager_window.py +274 -0
  48. moleditpy_linux/modules/settings_dialog.py +1503 -0
  49. moleditpy_linux/modules/template_preview_item.py +157 -0
  50. moleditpy_linux/modules/template_preview_view.py +74 -0
  51. moleditpy_linux/modules/translation_dialog.py +364 -0
  52. moleditpy_linux/modules/user_template_dialog.py +692 -0
  53. moleditpy_linux/modules/zoomable_view.py +129 -0
  54. moleditpy_linux-2.4.1.dist-info/METADATA +954 -0
  55. moleditpy_linux-2.4.1.dist-info/RECORD +59 -0
  56. moleditpy_linux-2.4.1.dist-info/WHEEL +5 -0
  57. moleditpy_linux-2.4.1.dist-info/entry_points.txt +2 -0
  58. moleditpy_linux-2.4.1.dist-info/licenses/LICENSE +674 -0
  59. 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
+