MoleditPy-linux 2.2.6__tar.gz → 2.3.0__tar.gz

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 (65) hide show
  1. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/PKG-INFO +2 -2
  2. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/pyproject.toml +2 -2
  3. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/MoleditPy_linux.egg-info/PKG-INFO +2 -2
  4. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/moleditpy_linux/modules/constants.py +1 -1
  5. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/moleditpy_linux/modules/main_window_main_init.py +51 -27
  6. moleditpy_linux-2.3.0/src/moleditpy_linux/modules/plugin_manager.py +448 -0
  7. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/moleditpy_linux/modules/plugin_manager_window.py +67 -14
  8. moleditpy_linux-2.2.6/src/moleditpy_linux/modules/plugin_manager.py +0 -309
  9. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/LICENSE +0 -0
  10. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/README.md +0 -0
  11. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/setup.cfg +0 -0
  12. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/MoleditPy_linux.egg-info/SOURCES.txt +0 -0
  13. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/MoleditPy_linux.egg-info/dependency_links.txt +0 -0
  14. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/MoleditPy_linux.egg-info/entry_points.txt +0 -0
  15. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/MoleditPy_linux.egg-info/requires.txt +0 -0
  16. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/MoleditPy_linux.egg-info/top_level.txt +0 -0
  17. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/moleditpy_linux/__init__.py +0 -0
  18. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/moleditpy_linux/__main__.py +0 -0
  19. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/moleditpy_linux/main.py +0 -0
  20. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/moleditpy_linux/modules/__init__.py +0 -0
  21. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/moleditpy_linux/modules/about_dialog.py +0 -0
  22. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/moleditpy_linux/modules/align_plane_dialog.py +0 -0
  23. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/moleditpy_linux/modules/alignment_dialog.py +0 -0
  24. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/moleditpy_linux/modules/analysis_window.py +0 -0
  25. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/moleditpy_linux/modules/angle_dialog.py +0 -0
  26. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/moleditpy_linux/modules/assets/file_icon.ico +0 -0
  27. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/moleditpy_linux/modules/assets/icon.icns +0 -0
  28. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/moleditpy_linux/modules/assets/icon.ico +0 -0
  29. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/moleditpy_linux/modules/assets/icon.png +0 -0
  30. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/moleditpy_linux/modules/atom_item.py +0 -0
  31. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/moleditpy_linux/modules/bond_item.py +0 -0
  32. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/moleditpy_linux/modules/bond_length_dialog.py +0 -0
  33. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/moleditpy_linux/modules/calculation_worker.py +0 -0
  34. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/moleditpy_linux/modules/color_settings_dialog.py +0 -0
  35. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/moleditpy_linux/modules/constrained_optimization_dialog.py +0 -0
  36. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/moleditpy_linux/modules/custom_interactor_style.py +0 -0
  37. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/moleditpy_linux/modules/custom_qt_interactor.py +0 -0
  38. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/moleditpy_linux/modules/dialog3_d_picking_mixin.py +0 -0
  39. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/moleditpy_linux/modules/dihedral_dialog.py +0 -0
  40. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/moleditpy_linux/modules/main_window.py +0 -0
  41. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/moleditpy_linux/modules/main_window_app_state.py +0 -0
  42. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/moleditpy_linux/modules/main_window_compute.py +0 -0
  43. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/moleditpy_linux/modules/main_window_dialog_manager.py +0 -0
  44. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/moleditpy_linux/modules/main_window_edit_3d.py +0 -0
  45. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/moleditpy_linux/modules/main_window_edit_actions.py +0 -0
  46. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/moleditpy_linux/modules/main_window_export.py +0 -0
  47. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/moleditpy_linux/modules/main_window_molecular_parsers.py +0 -0
  48. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/moleditpy_linux/modules/main_window_project_io.py +0 -0
  49. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/moleditpy_linux/modules/main_window_string_importers.py +0 -0
  50. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/moleditpy_linux/modules/main_window_ui_manager.py +0 -0
  51. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/moleditpy_linux/modules/main_window_view_3d.py +0 -0
  52. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/moleditpy_linux/modules/main_window_view_loaders.py +0 -0
  53. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/moleditpy_linux/modules/mirror_dialog.py +0 -0
  54. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/moleditpy_linux/modules/molecular_data.py +0 -0
  55. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/moleditpy_linux/modules/molecule_scene.py +0 -0
  56. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/moleditpy_linux/modules/move_group_dialog.py +0 -0
  57. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/moleditpy_linux/modules/periodic_table_dialog.py +0 -0
  58. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/moleditpy_linux/modules/planarize_dialog.py +0 -0
  59. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/moleditpy_linux/modules/plugin_interface.py +0 -0
  60. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/moleditpy_linux/modules/settings_dialog.py +0 -0
  61. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/moleditpy_linux/modules/template_preview_item.py +0 -0
  62. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/moleditpy_linux/modules/template_preview_view.py +0 -0
  63. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/moleditpy_linux/modules/translation_dialog.py +0 -0
  64. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/moleditpy_linux/modules/user_template_dialog.py +0 -0
  65. {moleditpy_linux-2.2.6 → moleditpy_linux-2.3.0}/src/moleditpy_linux/modules/zoomable_view.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: MoleditPy-linux
3
- Version: 2.2.6
3
+ Version: 2.3.0
4
4
  Summary: A cross-platform, simple, and intuitive molecular structure editor built in Python. It allows 2D molecular drawing and 3D structure visualization. It supports exporting structure files for input to DFT calculation software.
5
5
  Author-email: HiroYokoyama <titech.yoko.hiro@gmail.com>
6
6
  License: GNU GENERAL PUBLIC LICENSE
@@ -689,7 +689,7 @@ Classifier: Programming Language :: Python :: 3.10
689
689
  Classifier: Programming Language :: Python :: 3.11
690
690
  Classifier: Programming Language :: Python :: 3.12
691
691
  Classifier: Programming Language :: Python :: 3.13
692
- Requires-Python: <3.14,>=3.9
692
+ Requires-Python: <3.15,>=3.9
693
693
  Description-Content-Type: text/markdown
694
694
  License-File: LICENSE
695
695
  Requires-Dist: numpy
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
5
5
  [project]
6
6
  name = "MoleditPy-linux"
7
7
 
8
- version = "2.2.6"
8
+ version = "2.3.0"
9
9
 
10
10
  license = {file = "LICENSE"}
11
11
 
@@ -17,7 +17,7 @@ description = "A cross-platform, simple, and intuitive molecular structure edito
17
17
 
18
18
  readme = "README.md"
19
19
 
20
- requires-python = ">=3.9, <3.14"
20
+ requires-python = ">=3.9, <3.15"
21
21
 
22
22
  classifiers = [
23
23
  "Programming Language :: Python :: 3",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: MoleditPy-linux
3
- Version: 2.2.6
3
+ Version: 2.3.0
4
4
  Summary: A cross-platform, simple, and intuitive molecular structure editor built in Python. It allows 2D molecular drawing and 3D structure visualization. It supports exporting structure files for input to DFT calculation software.
5
5
  Author-email: HiroYokoyama <titech.yoko.hiro@gmail.com>
6
6
  License: GNU GENERAL PUBLIC LICENSE
@@ -689,7 +689,7 @@ Classifier: Programming Language :: Python :: 3.10
689
689
  Classifier: Programming Language :: Python :: 3.11
690
690
  Classifier: Programming Language :: Python :: 3.12
691
691
  Classifier: Programming Language :: Python :: 3.13
692
- Requires-Python: <3.14,>=3.9
692
+ Requires-Python: <3.15,>=3.9
693
693
  Description-Content-Type: text/markdown
694
694
  License-File: LICENSE
695
695
  Requires-Dist: numpy
@@ -16,7 +16,7 @@ from PyQt6.QtGui import QFont, QColor
16
16
  from rdkit import Chem
17
17
 
18
18
  #Version
19
- VERSION = '2.2.6'
19
+ VERSION = '2.3.0'
20
20
 
21
21
  ATOM_RADIUS = 18
22
22
  BOND_OFFSET = 3.5
@@ -1878,39 +1878,63 @@ class MainWindowMainInit(object):
1878
1878
  no_plugin_action.setEnabled(False)
1879
1879
  plugin_menu.addAction(no_plugin_action)
1880
1880
  else:
1881
- # Sort plugins: directories first (to create menus), then alphabetical by name
1882
- plugins.sort(key=lambda x: (x.get('rel_folder', ''), x['name']))
1881
+ # Sort plugins:
1882
+ # 1. Categories (A-Z)
1883
+ # 2. Within Category: Items (A-Z)
1884
+ # 3. Root items (A-Z)
1885
+
1886
+ # Group plugins by category
1887
+ categorized_plugins = {}
1888
+ root_plugins = []
1883
1889
 
1884
- # Dictionary to keep track of created submenus: path -> QMenu
1885
- menus = { "": plugin_menu }
1886
-
1887
1890
  for p in plugins:
1888
- # Only add legacy plugins (with 'run' function) to the generic Plugins menu.
1889
1891
  if hasattr(p['module'], 'run'):
1890
- rel_folder = p.get('rel_folder', '')
1891
- # Get or create the parent menu for this plugin
1892
- parent_menu = menus.get("") # Start at root
1893
-
1894
- if rel_folder:
1895
- # Split path and traverse/create submenus
1896
- parts = rel_folder.split(os.sep)
1897
- current_path = ""
1898
- for part in parts:
1899
- new_path = os.path.join(current_path, part) if current_path else part
1900
-
1901
- if new_path not in menus:
1902
- # Create new submenu
1903
- sub_menu = parent_menu.addMenu(part)
1904
- menus[new_path] = sub_menu
1905
-
1906
- parent_menu = menus[new_path]
1907
- current_path = new_path
1908
-
1909
- # Add action to the resolved parent_menu
1892
+ category = p.get('category', p.get('rel_folder', '')).strip()
1893
+ if category:
1894
+ if category not in categorized_plugins:
1895
+ categorized_plugins[category] = []
1896
+ categorized_plugins[category].append(p)
1897
+ else:
1898
+ root_plugins.append(p)
1899
+
1900
+ # Sort categories
1901
+ sorted_categories = sorted(categorized_plugins.keys())
1902
+
1903
+ # Build menu: Categories first
1904
+ for cat in sorted_categories:
1905
+ # Create/Get Category Menu (Nested support)
1906
+ parts = cat.split(os.sep)
1907
+ parent_menu = plugin_menu
1908
+
1909
+ # Traverse/Create nested menus
1910
+ for part in parts:
1911
+ found_sub = False
1912
+ for act in parent_menu.actions():
1913
+ if act.menu() and act.text().replace('&', '') == part:
1914
+ parent_menu = act.menu()
1915
+ found_sub = True
1916
+ break
1917
+ if not found_sub:
1918
+ parent_menu = parent_menu.addMenu(part)
1919
+
1920
+ # Add items to the leaf category menu (Sorted A-Z)
1921
+ cat_items = sorted(categorized_plugins[cat], key=lambda x: x['name'])
1922
+ for p in cat_items:
1910
1923
  action = QAction(p['name'], self)
1911
- action.triggered.connect(lambda checked, mod=p['module']: self.plugin_manager.run_plugin(mod, self))
1924
+ action.triggered.connect(lambda checked, mod=p['module']: self.plugin_manager.run_plugin(mod, self))
1912
1925
  parent_menu.addAction(action)
1913
1926
 
1927
+ # Add separator if needed <-- REMOVED per user request
1928
+ # if sorted_categories and root_plugins:
1929
+ # plugin_menu.addSeparator()
1930
+
1931
+ # Build menu: Root items last (Sorted A-Z)
1932
+ root_plugins.sort(key=lambda x: x['name'])
1933
+ for p in root_plugins:
1934
+ action = QAction(p['name'], self)
1935
+ action.triggered.connect(lambda checked, mod=p['module']: self.plugin_manager.run_plugin(mod, self))
1936
+ plugin_menu.addAction(action)
1937
+
1914
1938
  # 4. Integrate Export Actions into Export Button and Menu
1915
1939
  # 4. Integrate Export Actions into Export Button AND Main File->Export Menu
1916
1940
  if self.plugin_manager.export_actions:
@@ -0,0 +1,448 @@
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 = {}
50
+ self.analysis_tools = []
51
+ self.save_handlers = {}
52
+ self.load_handlers = {}
53
+ self.custom_3d_styles = {} # style_name -> {'plugin': name, 'callback': func}
54
+
55
+ def get_main_window(self):
56
+ return self.main_window
57
+
58
+ def set_main_window(self, mw):
59
+ self.main_window = mw
60
+
61
+ def ensure_plugin_dir(self):
62
+ """Creates the plugin directory if it doesn't exist."""
63
+ if not os.path.exists(self.plugin_dir):
64
+ try:
65
+ os.makedirs(self.plugin_dir)
66
+ except OSError as e:
67
+ print(f"Error creating plugin directory: {e}")
68
+
69
+ def open_plugin_folder(self):
70
+ """Opens the plugin directory in the OS file explorer."""
71
+ self.ensure_plugin_dir()
72
+ QDesktopServices.openUrl(QUrl.fromLocalFile(self.plugin_dir))
73
+
74
+ def install_plugin(self, file_path):
75
+ """Copies a plugin file to the plugin directory. Supports .py and .zip."""
76
+ self.ensure_plugin_dir()
77
+ try:
78
+ # Handle trailing slash and normalize path
79
+ file_path = os.path.normpath(file_path)
80
+ filename = os.path.basename(file_path)
81
+
82
+ if os.path.isdir(file_path):
83
+ # Copy entire directory
84
+ dest_path = os.path.join(self.plugin_dir, filename)
85
+ if os.path.exists(dest_path):
86
+ # Option 1: Overwrite (remove then copy) - safer for clean install
87
+ if os.path.isdir(dest_path):
88
+ shutil.rmtree(dest_path)
89
+ else:
90
+ os.remove(dest_path)
91
+
92
+ # Copy directory, ignoring cache files
93
+ shutil.copytree(file_path, dest_path, ignore=shutil.ignore_patterns('__pycache__', '*.pyc', '.git'))
94
+ msg = f"Installed package {filename}"
95
+ elif filename.lower().endswith('.zip'):
96
+ # Extract ZIP contents
97
+ with zipfile.ZipFile(file_path, 'r') as zf:
98
+ # Smart Extraction: Check if ZIP has a single top-level folder
99
+ # Fix for paths with backslashes on Windows if zip was created on Windows
100
+ roots = set()
101
+ for name in zf.namelist():
102
+ # Normalize path separators to forward slash for consistent check
103
+ name = name.replace('\\', '/')
104
+ parts = name.split('/')
105
+ if parts[0]:
106
+ roots.add(parts[0])
107
+
108
+ is_nested = (len(roots) == 1)
109
+
110
+ if is_nested:
111
+ # Case A: ZIP contains a single folder (e.g. MyPlugin/init.py)
112
+ top_folder = list(roots)[0]
113
+
114
+ # Guard: If the single item is __init__.py, we MUST create a wrapper folder
115
+ # otherwise we pollute the plugin_dir root.
116
+ if top_folder == "__init__.py":
117
+ is_nested = False
118
+
119
+ if is_nested:
120
+ # Case A (Confirmed): Extract directly
121
+ dest_path = os.path.join(self.plugin_dir, top_folder)
122
+
123
+ # Clean Install: Remove existing folder to prevent stale files
124
+ if os.path.exists(dest_path):
125
+ if os.path.isdir(dest_path):
126
+ shutil.rmtree(dest_path)
127
+ else:
128
+ os.remove(dest_path)
129
+
130
+ zf.extractall(self.plugin_dir)
131
+ msg = f"Installed package {top_folder} (from ZIP)"
132
+ else:
133
+ # Case B: ZIP is flat (e.g. file1.py, file2.py or just __init__.py)
134
+ # Extract into a new folder named after the ZIP file
135
+ folder_name = os.path.splitext(filename)[0]
136
+ dest_path = os.path.join(self.plugin_dir, folder_name)
137
+
138
+ if os.path.exists(dest_path):
139
+ if os.path.isdir(dest_path):
140
+ shutil.rmtree(dest_path)
141
+ else:
142
+ os.remove(dest_path)
143
+
144
+ os.makedirs(dest_path)
145
+ zf.extractall(dest_path)
146
+ msg = f"Installed package {folder_name} (from Flat ZIP)"
147
+ else:
148
+ # Standard file copy
149
+ dest_path = os.path.join(self.plugin_dir, filename)
150
+ if os.path.exists(dest_path):
151
+ if os.path.isdir(dest_path):
152
+ import shutil
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
+
187
+ if not os.path.exists(self.plugin_dir):
188
+ return []
189
+
190
+ for root, dirs, files in os.walk(self.plugin_dir):
191
+ # Exclude hidden directories
192
+ dirs[:] = [d for d in dirs if not d.startswith('__') and d != '__pycache__']
193
+
194
+ # [Check] Is current dir a package (plugin body)?
195
+ if "__init__.py" in files:
196
+ # === Case 1: Package Plugin (Folder is the plugin) ===
197
+
198
+ # Stop recursion into this folder
199
+ dirs[:] = []
200
+
201
+ entry_point = os.path.join(root, "__init__.py")
202
+ # Category is relative path to parent folder
203
+ rel_path = os.path.relpath(os.path.dirname(root), self.plugin_dir)
204
+ category = rel_path if rel_path != "." else ""
205
+
206
+ # Module name is the folder name
207
+ module_name = os.path.basename(root)
208
+
209
+ self._load_single_plugin(entry_point, module_name, category)
210
+
211
+ else:
212
+ # === Case 2: Category Folder (Load individual .py files) ===
213
+
214
+ # Category is relative path to current folder
215
+ rel_path = os.path.relpath(root, self.plugin_dir)
216
+ category = rel_path if rel_path != "." else ""
217
+
218
+ for filename in files:
219
+ if filename.endswith(".py") and not filename.startswith("__"):
220
+ entry_point = os.path.join(root, filename)
221
+ module_name = os.path.splitext(filename)[0]
222
+
223
+ self._load_single_plugin(entry_point, module_name, category)
224
+
225
+ return self.plugins
226
+
227
+ def _load_single_plugin(self, filepath, module_name, category):
228
+ """Common loading logic for both single-file and package plugins."""
229
+ try:
230
+ # Ensure unique module name by including category path
231
+ # e.g. Analysis.Docking
232
+ unique_module_name = f"{category.replace(os.sep, '.')}.{module_name}" if category else module_name
233
+ unique_module_name = unique_module_name.strip(".")
234
+
235
+ spec = importlib.util.spec_from_file_location(unique_module_name, filepath)
236
+ if spec and spec.loader:
237
+ module = importlib.util.module_from_spec(spec)
238
+ sys.modules[spec.name] = module
239
+
240
+ # Inject category info
241
+ module.PLUGIN_CATEGORY = category
242
+
243
+ spec.loader.exec_module(module)
244
+
245
+ # --- Metadata Extraction ---
246
+ # Metadata
247
+ # Priority: PLUGIN_XXX > __xxx__ > Fallback
248
+ plugin_name = getattr(module, 'PLUGIN_NAME', module_name)
249
+ plugin_version = getattr(module, 'PLUGIN_VERSION', getattr(module, '__version__', 'Unknown'))
250
+ plugin_author = getattr(module, 'PLUGIN_AUTHOR', getattr(module, '__author__', 'Unknown'))
251
+ plugin_desc = getattr(module, 'PLUGIN_DESCRIPTION', getattr(module, '__doc__', ''))
252
+ plugin_category = getattr(module, 'PLUGIN_CATEGORY', category)
253
+
254
+ # Additional cleanup for docstring (strip whitespace)
255
+ if plugin_desc is None: plugin_desc = ""
256
+ plugin_desc = str(plugin_desc).strip()
257
+
258
+ # Handle version tuple
259
+ if isinstance(plugin_version, tuple):
260
+ plugin_version = ".".join(map(str, plugin_version))
261
+
262
+ # Interface compliance
263
+ has_run = hasattr(module, 'run') and callable(module.run)
264
+ has_autorun = hasattr(module, 'autorun') and callable(module.autorun)
265
+ has_init = hasattr(module, 'initialize') and callable(module.initialize)
266
+
267
+ status = "Loaded"
268
+
269
+ # Execute initialization
270
+ if has_init:
271
+ context = PluginContext(self, plugin_name)
272
+ # Pass category to context if needed, currently not storing it in context directly but could be useful
273
+ try:
274
+ module.initialize(context)
275
+ except Exception as e:
276
+ status = f"Error (Init): {e}"
277
+ print(f"Plugin {plugin_name} initialize error: {e}")
278
+ traceback.print_exc()
279
+ elif has_autorun:
280
+ try:
281
+ if self.main_window:
282
+ module.autorun(self.main_window)
283
+ else:
284
+ status = "Skipped (No MW)"
285
+ except Exception as e:
286
+ status = f"Error (Autorun): {e}"
287
+ print(f"Plugin {plugin_name} autorun error: {e}")
288
+ traceback.print_exc()
289
+ elif not has_run:
290
+ status = "No Entry Point"
291
+
292
+ self.plugins.append({
293
+ 'name': plugin_name,
294
+ 'version': plugin_version,
295
+ 'author': plugin_author,
296
+ 'description': plugin_desc,
297
+ 'module': module,
298
+ 'category': plugin_category, # Store category
299
+ 'status': status,
300
+ 'filepath': filepath,
301
+ 'has_run': has_run
302
+ })
303
+
304
+ except Exception as e:
305
+ print(f"Failed to load plugin {module_name}: {e}")
306
+ traceback.print_exc()
307
+
308
+ def run_plugin(self, module, main_window):
309
+ """Executes the plugin's run method (Legacy manual trigger)."""
310
+ try:
311
+ module.run(main_window)
312
+ except Exception as e:
313
+ QMessageBox.critical(main_window, "Plugin Error", f"Error running plugin '{getattr(module, 'PLUGIN_NAME', 'Unknown')}':\n{e}")
314
+ traceback.print_exc()
315
+
316
+ # --- Registration Callbacks ---
317
+ def register_menu_action(self, plugin_name, path, callback, text, icon, shortcut):
318
+ self.menu_actions.append({
319
+ 'plugin': plugin_name, 'path': path, 'callback': callback,
320
+ 'text': text, 'icon': icon, 'shortcut': shortcut
321
+ })
322
+
323
+ def register_toolbar_action(self, plugin_name, callback, text, icon, tooltip):
324
+ self.toolbar_actions.append({
325
+ 'plugin': plugin_name, 'callback': callback,
326
+ 'text': text, 'icon': icon, 'tooltip': tooltip
327
+ })
328
+
329
+
330
+
331
+ def register_drop_handler(self, plugin_name, callback, priority):
332
+ self.drop_handlers.append({
333
+ 'priority': priority, 'plugin': plugin_name, 'callback': callback
334
+ })
335
+ # Sort by priority desc
336
+ self.drop_handlers.sort(key=lambda x: x['priority'], reverse=True)
337
+
338
+ def register_export_action(self, plugin_name, label, callback):
339
+ self.export_actions.append({
340
+ 'plugin': plugin_name, 'label': label, 'callback': callback
341
+ })
342
+
343
+ def register_optimization_method(self, plugin_name, method_name, callback):
344
+ # Key by upper-case method name for consistency
345
+ self.optimization_methods[method_name.upper()] = {
346
+ 'plugin': plugin_name, 'callback': callback, 'label': method_name
347
+ }
348
+
349
+ def register_file_opener(self, plugin_name, extension, callback):
350
+ # Normalize extension to lowercase
351
+ ext = extension.lower()
352
+ if not ext.startswith('.'):
353
+ ext = '.' + ext
354
+ self.file_openers[ext] = {
355
+ 'plugin': plugin_name, 'callback': callback
356
+ }
357
+
358
+ # Analysis Tools registration
359
+ def register_analysis_tool(self, plugin_name, label, callback):
360
+ self.analysis_tools.append({'plugin': plugin_name, 'label': label, 'callback': callback})
361
+
362
+ # State Persistence registration
363
+ def register_save_handler(self, plugin_name, callback):
364
+ self.save_handlers[plugin_name] = callback
365
+
366
+ def register_load_handler(self, plugin_name, callback):
367
+ self.load_handlers[plugin_name] = callback
368
+
369
+ def register_3d_style(self, plugin_name, style_name, callback):
370
+ self.custom_3d_styles[style_name] = {
371
+ 'plugin': plugin_name, 'callback': callback
372
+ }
373
+
374
+ def get_plugin_info_safe(self, file_path):
375
+ """Extracts plugin metadata using AST parsing (safe, no execution)."""
376
+ info = {
377
+ 'name': os.path.basename(file_path),
378
+ 'version': 'Unknown',
379
+ 'author': 'Unknown',
380
+ 'description': ''
381
+ }
382
+ try:
383
+ with open(file_path, "r", encoding="utf-8") as f:
384
+ tree = ast.parse(f.read())
385
+
386
+ for node in tree.body:
387
+ targets = []
388
+ if isinstance(node, ast.Assign):
389
+ targets = node.targets
390
+ elif isinstance(node, ast.AnnAssign):
391
+ targets = [node.target]
392
+
393
+ for target in targets:
394
+ if isinstance(target, ast.Name):
395
+ # Helper to extract value
396
+ val = None
397
+ if node.value: # AnnAssign might presumably not have value? (though usually does for module globals)
398
+ if isinstance(node.value, ast.Constant): # Py3.8+
399
+ val = node.value.value
400
+ elif hasattr(ast, 'Str') and isinstance(node.value, ast.Str): # Py3.7 and below
401
+ val = node.value.s
402
+ elif isinstance(node.value, ast.Tuple):
403
+ # Handle version tuples e.g. (1, 0, 0)
404
+ try:
405
+ # Extract simple constants from tuple
406
+ elts = []
407
+ for elt in node.value.elts:
408
+ if isinstance(elt, ast.Constant):
409
+ elts.append(elt.value)
410
+ elif hasattr(ast, 'Num') and isinstance(elt, ast.Num):
411
+ elts.append(elt.n)
412
+ val = ".".join(map(str, elts))
413
+ except:
414
+ pass
415
+
416
+ if val is not None:
417
+ if target.id == 'PLUGIN_NAME':
418
+ info['name'] = val
419
+ elif target.id == 'PLUGIN_VERSION':
420
+ info['version'] = val
421
+ elif target.id == 'PLUGIN_AUTHOR':
422
+ info['author'] = val
423
+ elif target.id == 'PLUGIN_DESCRIPTION':
424
+ info['description'] = val
425
+ elif target.id == 'PLUGIN_CATEGORY':
426
+ info['category'] = val
427
+ elif target.id == '__version__' and info['version'] == 'Unknown':
428
+ info['version'] = val
429
+ elif target.id == '__author__' and info['author'] == 'Unknown':
430
+ info['author'] = val
431
+
432
+ # Docstring extraction
433
+ if isinstance(node, ast.Expr):
434
+ val = None
435
+ if isinstance(node.value, ast.Constant) and isinstance(node.value.value, str):
436
+ val = node.value.value
437
+ elif hasattr(ast, 'Str') and isinstance(node.value, ast.Str):
438
+ val = node.value.s
439
+
440
+ if val and not info['description']:
441
+ info['description'] = val.strip().split('\n')[0]
442
+
443
+ except Exception as e:
444
+ print(f"Error parsing plugin info: {e}")
445
+ return info
446
+
447
+
448
+
@@ -18,6 +18,7 @@ from PyQt6.QtWidgets import (
18
18
  )
19
19
  from PyQt6.QtCore import Qt, QMimeData, QUrl
20
20
  from PyQt6.QtGui import QDragEnterEvent, QDropEvent, QDesktopServices
21
+ import shutil
21
22
 
22
23
  class PluginManagerWindow(QDialog):
23
24
  def __init__(self, plugin_manager, parent=None):
@@ -33,8 +34,7 @@ class PluginManagerWindow(QDialog):
33
34
  def init_ui(self):
34
35
  layout = QVBoxLayout(self)
35
36
 
36
- # Header / Instruction
37
- lbl_info = QLabel("Drag & Drop .py files here to install plugins.")
37
+ lbl_info = QLabel("Drag & Drop .py or .zip files here to install plugins.")
38
38
  lbl_info.setStyleSheet("color: gray; font-style: italic;")
39
39
  layout.addWidget(lbl_info)
40
40
 
@@ -152,16 +152,31 @@ class PluginManagerWindow(QDialog):
152
152
  filepath = plugin.get('filepath')
153
153
 
154
154
  if filepath and os.path.exists(filepath):
155
- reply = QMessageBox.question(self, "Remove Plugin",
156
- f"Are you sure you want to remove '{plugin.get('name', 'Unknown')}'?\n\nFile: {filepath}\nThis cannot be undone.",
157
- QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
158
- if reply == QMessageBox.StandardButton.Yes:
159
- try:
160
- os.remove(filepath)
161
- self.on_reload(silent=True) # Reload list and plugins
162
- QMessageBox.information(self, "Success", f"Removed '{plugin.get('name', 'Unknown')}'.")
163
- except Exception as e:
164
- QMessageBox.critical(self, "Error", f"Failed to delete file: {e}")
155
+ # Check if it is a package plugin (based on __init__.py)
156
+ is_package = os.path.basename(filepath) == "__init__.py"
157
+ target_path = os.path.dirname(filepath) if is_package else filepath
158
+
159
+ msg = f"Are you sure you want to remove '{plugin.get('name', 'Unknown')}'?"
160
+ if is_package:
161
+ msg += f"\n\nThis will delete the entire folder:\n{target_path}"
162
+ else:
163
+ msg += f"\n\nFile: {filepath}"
164
+
165
+ msg += "\nThis cannot be undone."
166
+
167
+ reply = QMessageBox.question(self, "Remove Plugin", msg,
168
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
169
+ if reply == QMessageBox.StandardButton.Yes:
170
+ try:
171
+ if is_package:
172
+ shutil.rmtree(target_path)
173
+ else:
174
+ os.remove(target_path)
175
+
176
+ self.on_reload(silent=True) # Reload list and plugins
177
+ QMessageBox.information(self, "Success", f"Removed '{plugin.get('name', 'Unknown')}'.")
178
+ except Exception as e:
179
+ QMessageBox.critical(self, "Error", f"Failed to delete plugin: {e}")
165
180
  else:
166
181
  QMessageBox.warning(self, "Error", f"Plugin file not found:\n{filepath}")
167
182
 
@@ -189,9 +204,47 @@ class PluginManagerWindow(QDialog):
189
204
  errors = []
190
205
  for url in event.mimeData().urls():
191
206
  file_path = url.toLocalFile()
192
- if os.path.isfile(file_path) and file_path.endswith('.py'):
207
+
208
+ is_valid = False
209
+ is_zip = False
210
+ is_folder = False
211
+
212
+ if os.path.isfile(file_path):
213
+ # Special handling: If user drops __init__.py, assume they want to install the package (folder)
214
+ if os.path.basename(file_path) == "__init__.py":
215
+ file_path = os.path.dirname(file_path)
216
+ is_valid = True
217
+ is_folder = True
218
+ elif file_path.endswith('.py'):
219
+ is_valid = True
220
+ elif file_path.endswith('.zip'):
221
+ is_valid = True
222
+ is_zip = True
223
+
224
+ if os.path.isdir(file_path):
225
+ # Check for __init__.py to confirm it's a plugin package?
226
+ # Or just assume any folder is fair game (could be category folder too?)
227
+ # We'll allow any folder and let manager handle it.
228
+ is_valid = True
229
+ is_folder = True
230
+
231
+ if is_valid:
193
232
  # Extract info and confirm
194
- info = self.plugin_manager.get_plugin_info_safe(file_path)
233
+ info = {'name': os.path.basename(file_path), 'version': 'Unknown', 'author': 'Unknown', 'description': ''}
234
+
235
+ if is_folder:
236
+ info['description'] = "Folder Plugin / Category"
237
+ # Try to parse __init__.py if it exists
238
+ init_path = os.path.join(file_path, "__init__.py")
239
+ if os.path.exists(init_path):
240
+ info = self.plugin_manager.get_plugin_info_safe(init_path)
241
+ info['description'] += f" (Package: {info['name']})"
242
+
243
+ elif is_zip:
244
+ info['description'] = "ZIP Package Plugin"
245
+ elif file_path.endswith('.py'):
246
+ info = self.plugin_manager.get_plugin_info_safe(file_path)
247
+
195
248
  msg = (f"Do you want to install this plugin?\n\n"
196
249
  f"Name: {info['name']}\n"
197
250
  f"Author: {info['author']}\n"
@@ -1,309 +0,0 @@
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 importlib.util
22
- import traceback
23
- import ast
24
- from PyQt6.QtGui import QDesktopServices
25
- from PyQt6.QtCore import QUrl
26
- from PyQt6.QtWidgets import QMessageBox
27
-
28
- try:
29
- from .plugin_interface import PluginContext
30
- except ImportError:
31
- # Fallback if running as script
32
- from modules.plugin_interface import PluginContext
33
-
34
- class PluginManager:
35
- def __init__(self, main_window=None):
36
- self.plugin_dir = os.path.join(os.path.expanduser('~'), '.moleditpy', 'plugins')
37
- self.plugins = [] # List of dicts
38
- self.main_window = main_window
39
-
40
- # Registries for actions
41
- self.menu_actions = [] # List of (plugin_name, path, callback, text, icon, shortcut)
42
- self.toolbar_actions = []
43
- self.drop_handlers = [] # List of (priority, plugin_name, callback)
44
-
45
- # Extended Registries (Added to prevent lazy initialization "monkey patching")
46
- self.export_actions = []
47
- self.optimization_methods = {}
48
- self.file_openers = {}
49
- self.analysis_tools = []
50
- self.save_handlers = {}
51
- self.load_handlers = {}
52
- self.custom_3d_styles = {} # style_name -> {'plugin': name, 'callback': func}
53
-
54
- def get_main_window(self):
55
- return self.main_window
56
-
57
- def set_main_window(self, mw):
58
- self.main_window = mw
59
-
60
- def ensure_plugin_dir(self):
61
- """Creates the plugin directory if it doesn't exist."""
62
- if not os.path.exists(self.plugin_dir):
63
- try:
64
- os.makedirs(self.plugin_dir)
65
- except OSError as e:
66
- print(f"Error creating plugin directory: {e}")
67
-
68
- def open_plugin_folder(self):
69
- """Opens the plugin directory in the OS file explorer."""
70
- self.ensure_plugin_dir()
71
- QDesktopServices.openUrl(QUrl.fromLocalFile(self.plugin_dir))
72
-
73
- def install_plugin(self, file_path):
74
- """Copies a plugin file to the plugin directory."""
75
- self.ensure_plugin_dir()
76
- try:
77
- filename = os.path.basename(file_path)
78
- dest_path = os.path.join(self.plugin_dir, filename)
79
- shutil.copy2(file_path, dest_path)
80
- # Reload plugins after install
81
- if self.main_window:
82
- self.discover_plugins(self.main_window)
83
- return True, f"Installed {filename}"
84
- except Exception as e:
85
- return False, str(e)
86
-
87
- def discover_plugins(self, parent=None):
88
- """
89
- Recursively scans the plugin directory.
90
- Supports both legacy autorun(parent) and new initialize(context).
91
- """
92
- if parent:
93
- self.main_window = parent
94
-
95
- self.ensure_plugin_dir()
96
- self.plugins = []
97
- self.menu_actions = []
98
- self.toolbar_actions = []
99
- self.drop_handlers = []
100
-
101
- # Clear extended registries
102
- self.export_actions = []
103
- self.optimization_methods = {}
104
- self.file_openers = {}
105
- self.analysis_tools = []
106
- self.save_handlers = {}
107
- self.load_handlers = {}
108
- self.custom_3d_styles = {}
109
-
110
- if not os.path.exists(self.plugin_dir):
111
- return []
112
-
113
- for root, dirs, files in os.walk(self.plugin_dir):
114
- # Modify dirs in-place to skip hidden directories and __pycache__
115
- dirs[:] = [d for d in dirs if not d.startswith('__') and d != '__pycache__']
116
-
117
- for filename in files:
118
- if filename.endswith(".py") and not filename.startswith("__"):
119
- filepath = os.path.join(root, filename)
120
- rel_folder = os.path.relpath(root, self.plugin_dir)
121
- if rel_folder == '.':
122
- rel_folder = ""
123
-
124
- try:
125
- module_name = os.path.splitext(os.path.relpath(filepath, self.plugin_dir))[0].replace(os.sep, '.')
126
-
127
- spec = importlib.util.spec_from_file_location(module_name, filepath)
128
- if spec and spec.loader:
129
- module = importlib.util.module_from_spec(spec)
130
- sys.modules[spec.name] = module
131
- spec.loader.exec_module(module)
132
-
133
- # --- Metadata Extraction ---
134
- plugin_name = getattr(module, 'PLUGIN_NAME', filename[:-3])
135
- plugin_version = getattr(module, 'PLUGIN_VERSION', getattr(module, '__version__', 'Unknown'))
136
- plugin_author = getattr(module, 'PLUGIN_AUTHOR', getattr(module, '__author__', 'Unknown'))
137
- plugin_desc = getattr(module, 'PLUGIN_DESCRIPTION', getattr(module, '__doc__', ''))
138
-
139
- # Clean up docstring if used as description
140
- if plugin_desc:
141
- plugin_desc = plugin_desc.strip().split('\n')[0]
142
-
143
- # check for interface compliance
144
- has_run = hasattr(module, 'run') and callable(module.run)
145
- has_autorun = hasattr(module, 'autorun') and callable(module.autorun)
146
- has_init = hasattr(module, 'initialize') and callable(module.initialize)
147
-
148
- status = "Loaded"
149
-
150
- # Execute loading logic
151
- if has_init:
152
- context = PluginContext(self, plugin_name)
153
- try:
154
- module.initialize(context)
155
- except Exception as e:
156
- status = f"Error (Init): {e}"
157
- print(f"Plugin {plugin_name} initialize error: {e}")
158
- traceback.print_exc()
159
- elif has_autorun:
160
- try:
161
- if self.main_window:
162
- module.autorun(self.main_window)
163
- else:
164
- status = "Skipped (No MW)"
165
- except Exception as e:
166
- status = f"Error (Autorun): {e}"
167
- print(f"Plugin {plugin_name} autorun error: {e}")
168
- traceback.print_exc()
169
- elif not has_run:
170
- status = "No Entry Point"
171
-
172
- self.plugins.append({
173
- 'name': plugin_name,
174
- 'version': plugin_version,
175
- 'author': plugin_author,
176
- 'description': plugin_desc,
177
- 'module': module,
178
- 'rel_folder': rel_folder,
179
- 'status': status,
180
- 'filepath': filepath,
181
- 'has_run': has_run # for menu manual run
182
- })
183
-
184
- except Exception as e:
185
- print(f"Failed to load plugin {filename}: {e}")
186
- traceback.print_exc()
187
-
188
- return self.plugins
189
-
190
- def run_plugin(self, module, main_window):
191
- """Executes the plugin's run method (Legacy manual trigger)."""
192
- try:
193
- module.run(main_window)
194
- except Exception as e:
195
- QMessageBox.critical(main_window, "Plugin Error", f"Error running plugin '{getattr(module, 'PLUGIN_NAME', 'Unknown')}':\n{e}")
196
- traceback.print_exc()
197
-
198
- # --- Registration Callbacks ---
199
- def register_menu_action(self, plugin_name, path, callback, text, icon, shortcut):
200
- self.menu_actions.append({
201
- 'plugin': plugin_name, 'path': path, 'callback': callback,
202
- 'text': text, 'icon': icon, 'shortcut': shortcut
203
- })
204
-
205
- def register_toolbar_action(self, plugin_name, callback, text, icon, tooltip):
206
- self.toolbar_actions.append({
207
- 'plugin': plugin_name, 'callback': callback,
208
- 'text': text, 'icon': icon, 'tooltip': tooltip
209
- })
210
-
211
-
212
-
213
- def register_drop_handler(self, plugin_name, callback, priority):
214
- self.drop_handlers.append({
215
- 'priority': priority, 'plugin': plugin_name, 'callback': callback
216
- })
217
- # Sort by priority desc
218
- self.drop_handlers.sort(key=lambda x: x['priority'], reverse=True)
219
-
220
- def register_export_action(self, plugin_name, label, callback):
221
- self.export_actions.append({
222
- 'plugin': plugin_name, 'label': label, 'callback': callback
223
- })
224
-
225
- def register_optimization_method(self, plugin_name, method_name, callback):
226
- # Key by upper-case method name for consistency
227
- self.optimization_methods[method_name.upper()] = {
228
- 'plugin': plugin_name, 'callback': callback, 'label': method_name
229
- }
230
-
231
- def register_file_opener(self, plugin_name, extension, callback):
232
- # Normalize extension to lowercase
233
- ext = extension.lower()
234
- if not ext.startswith('.'):
235
- ext = '.' + ext
236
- self.file_openers[ext] = {
237
- 'plugin': plugin_name, 'callback': callback
238
- }
239
-
240
- # Analysis Tools registration
241
- def register_analysis_tool(self, plugin_name, label, callback):
242
- self.analysis_tools.append({'plugin': plugin_name, 'label': label, 'callback': callback})
243
-
244
- # State Persistence registration
245
- def register_save_handler(self, plugin_name, callback):
246
- self.save_handlers[plugin_name] = callback
247
-
248
- def register_load_handler(self, plugin_name, callback):
249
- self.load_handlers[plugin_name] = callback
250
-
251
- def register_3d_style(self, plugin_name, style_name, callback):
252
- self.custom_3d_styles[style_name] = {
253
- 'plugin': plugin_name, 'callback': callback
254
- }
255
-
256
- def get_plugin_info_safe(self, file_path):
257
- """Extracts plugin metadata using AST parsing (safe, no execution)."""
258
- info = {
259
- 'name': os.path.basename(file_path),
260
- 'version': 'Unknown',
261
- 'author': 'Unknown',
262
- 'description': ''
263
- }
264
- try:
265
- with open(file_path, "r", encoding="utf-8") as f:
266
- tree = ast.parse(f.read())
267
-
268
- for node in tree.body:
269
- if isinstance(node, ast.Assign):
270
- for target in node.targets:
271
- if isinstance(target, ast.Name):
272
- # Helper to extract value
273
- val = None
274
- if isinstance(node.value, ast.Constant): # Py3.8+
275
- val = node.value.value
276
- elif hasattr(ast, 'Str') and isinstance(node.value, ast.Str): # Py3.7 and below
277
- val = node.value.s
278
-
279
- if val is not None:
280
- if target.id == 'PLUGIN_NAME':
281
- info['name'] = val
282
- elif target.id == 'PLUGIN_VERSION':
283
- info['version'] = val
284
- elif target.id == 'PLUGIN_AUTHOR':
285
- info['author'] = val
286
- elif target.id == 'PLUGIN_DESCRIPTION':
287
- info['description'] = val
288
- elif target.id == '__version__' and info['version'] == 'Unknown':
289
- info['version'] = val
290
- elif target.id == '__author__' and info['author'] == 'Unknown':
291
- info['author'] = val
292
-
293
- # Docstring extraction
294
- if isinstance(node, ast.Expr):
295
- val = None
296
- if isinstance(node.value, ast.Constant) and isinstance(node.value.value, str):
297
- val = node.value.value
298
- elif hasattr(ast, 'Str') and isinstance(node.value, ast.Str):
299
- val = node.value.s
300
-
301
- if val and not info['description']:
302
- info['description'] = val.strip().split('\n')[0]
303
-
304
- except Exception as e:
305
- print(f"Error parsing plugin info: {e}")
306
- return info
307
-
308
-
309
-
File without changes