MoleditPy 2.2.6__py3-none-any.whl → 2.3.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.
@@ -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:
@@ -18,6 +18,7 @@ Manages discovery, loading, and execution of external plugins.
18
18
  import os
19
19
  import sys
20
20
  import shutil
21
+ import zipfile
21
22
  import importlib.util
22
23
  import traceback
23
24
  import ast
@@ -71,34 +72,110 @@ class PluginManager:
71
72
  QDesktopServices.openUrl(QUrl.fromLocalFile(self.plugin_dir))
72
73
 
73
74
  def install_plugin(self, file_path):
74
- """Copies a plugin file to the plugin directory."""
75
+ """Copies a plugin file to the plugin directory. Supports .py and .zip."""
75
76
  self.ensure_plugin_dir()
76
77
  try:
78
+ # Handle trailing slash and normalize path
79
+ file_path = os.path.normpath(file_path)
77
80
  filename = os.path.basename(file_path)
78
- dest_path = os.path.join(self.plugin_dir, filename)
79
- shutil.copy2(file_path, dest_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
+
80
157
  # Reload plugins after install
81
158
  if self.main_window:
82
159
  self.discover_plugins(self.main_window)
83
- return True, f"Installed {filename}"
160
+ return True, msg
84
161
  except Exception as e:
85
162
  return False, str(e)
86
163
 
87
164
  def discover_plugins(self, parent=None):
88
165
  """
89
- Recursively scans the plugin directory.
90
- Supports both legacy autorun(parent) and new initialize(context).
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).
91
169
  """
92
170
  if parent:
93
171
  self.main_window = parent
94
172
 
95
173
  self.ensure_plugin_dir()
96
174
  self.plugins = []
175
+ # Clear registries
97
176
  self.menu_actions = []
98
177
  self.toolbar_actions = []
99
178
  self.drop_handlers = []
100
-
101
- # Clear extended registries
102
179
  self.export_actions = []
103
180
  self.optimization_methods = {}
104
181
  self.file_openers = {}
@@ -111,81 +188,122 @@ class PluginManager:
111
188
  return []
112
189
 
113
190
  for root, dirs, files in os.walk(self.plugin_dir):
114
- # Modify dirs in-place to skip hidden directories and __pycache__
191
+ # Exclude hidden directories
115
192
  dirs[:] = [d for d in dirs if not d.startswith('__') and d != '__pycache__']
116
193
 
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 = ""
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]
123
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
124
273
  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
-
274
+ module.initialize(context)
184
275
  except Exception as e:
185
- print(f"Failed to load plugin {filename}: {e}")
276
+ status = f"Error (Init): {e}"
277
+ print(f"Plugin {plugin_name} initialize error: {e}")
186
278
  traceback.print_exc()
187
-
188
- return self.plugins
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()
189
307
 
190
308
  def run_plugin(self, module, main_window):
191
309
  """Executes the plugin's run method (Legacy manual trigger)."""
@@ -266,29 +384,50 @@ class PluginManager:
266
384
  tree = ast.parse(f.read())
267
385
 
268
386
  for node in tree.body:
387
+ targets = []
269
388
  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
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)
274
398
  if isinstance(node.value, ast.Constant): # Py3.8+
275
399
  val = node.value.value
276
400
  elif hasattr(ast, 'Str') and isinstance(node.value, ast.Str): # Py3.7 and below
277
401
  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
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
292
431
 
293
432
  # Docstring extraction
294
433
  if isinstance(node, ast.Expr):
@@ -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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: MoleditPy
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
@@ -12,7 +12,7 @@ moleditpy/modules/bond_item.py,sha256=eVkEeKvM4igYI67DYxpey3FllqDyt_iWDo4VPYMhaP
12
12
  moleditpy/modules/bond_length_dialog.py,sha256=6bFPGssnqlgINuqpxLv-OhjMH3_hspnaH8QtorAyu2M,14782
13
13
  moleditpy/modules/calculation_worker.py,sha256=KiGQY7i-QCQofEoE0r65KoQgpEGFcbhmxWv6egfkUdc,42324
14
14
  moleditpy/modules/color_settings_dialog.py,sha256=Ow44BhCOLo0AFb6klO001k6B4drOgKX9DeNBQhZLp5o,15474
15
- moleditpy/modules/constants.py,sha256=_LJDuqJFfR_t_84zzl3p5dhK7C2LB-ZyFuBE4K0dUE4,4702
15
+ moleditpy/modules/constants.py,sha256=J9rIFaD6tWxdMBzGcaYWovarEJy72gXD784k9WVahr8,4702
16
16
  moleditpy/modules/constrained_optimization_dialog.py,sha256=REsk4ePsqNmAGPMTS_jckeM7jexrU3krwun8sKqKUCs,30062
17
17
  moleditpy/modules/custom_interactor_style.py,sha256=LDNODMJoNHGe1AUSrvqv6PdeJm-hpPmSpWINppnJLt0,38942
18
18
  moleditpy/modules/custom_qt_interactor.py,sha256=vCZsDfRO-FtphD5cTP7Ps-5rpHZMIGloaoe6EaKzrsw,4139
@@ -25,7 +25,7 @@ moleditpy/modules/main_window_dialog_manager.py,sha256=QR96LqHAPSOShXbc9cK-Ffq8a
25
25
  moleditpy/modules/main_window_edit_3d.py,sha256=CUArB5wcsgq1C7LygAEC6URlbnn4RhRYDa5n-Y-etWI,19731
26
26
  moleditpy/modules/main_window_edit_actions.py,sha256=yEc0Nw-VpN0P4e4neUu7pDuUHPGEcu6eFmwWFrSBIQ8,64815
27
27
  moleditpy/modules/main_window_export.py,sha256=dSVfylsybDDboDuXU9Inotf6YkrKJwgBTqGYSfq1lRE,38241
28
- moleditpy/modules/main_window_main_init.py,sha256=Shxs8_vVa7PhfhVZdHYtPniItLHKJ3i9wcamcVofvso,91172
28
+ moleditpy/modules/main_window_main_init.py,sha256=BChZuyXgWfOTFqAM1OUukFgtL5KLusW4t29GnfCN1QE,92018
29
29
  moleditpy/modules/main_window_molecular_parsers.py,sha256=KR6vzuqc3nutOcorpYr0QOyX3MFBcxTwDhZX96VgJ9Q,48291
30
30
  moleditpy/modules/main_window_project_io.py,sha256=TWwtuKDuvgcvPZ9IGmW8r1EJJOrgxrIJRnxe_f4C1oM,17149
31
31
  moleditpy/modules/main_window_string_importers.py,sha256=v47wOd4RtjKYcF-aLP-mogGGdYTpTEo3dDyAu79_5MM,10782
@@ -39,8 +39,8 @@ moleditpy/modules/move_group_dialog.py,sha256=Fyuy3Uq1KsFsk9qR96r_FxPbAM_-zSfW2d
39
39
  moleditpy/modules/periodic_table_dialog.py,sha256=ItEZUts1XCietz9paY-spvbzxh6SXak3GnikwqkHZCw,4006
40
40
  moleditpy/modules/planarize_dialog.py,sha256=eaqI1MpF35e-VUMpJATt-EtGG5FhcSUlbAenUaFGabY,8593
41
41
  moleditpy/modules/plugin_interface.py,sha256=srzPZ3a_aRTx28NAvWNKRVUDYQNfQOTcjzx-5YW2Pb4,8164
42
- moleditpy/modules/plugin_manager.py,sha256=cxbqIE7Rb_KeBd-cG1vF2ySY2qNx8IJVRXjVPyQMFDc,13457
43
- moleditpy/modules/plugin_manager_window.py,sha256=UeoPQWTxmckoFIQAuv9jVBzok_9gEKEm9c7JsKRX0P4,10135
42
+ moleditpy/modules/plugin_manager.py,sha256=nb106AfUAnAVyp4LYFmLhKzFY4taeteH8gY5SNf1h1I,20037
43
+ moleditpy/modules/plugin_manager_window.py,sha256=b4kEv0DaWHZG76ZaFTOxn6CVtA62_0MpPYYr10ehCtA,12544
44
44
  moleditpy/modules/settings_dialog.py,sha256=Nr7yE8UmYRi3VObWvRlrnv0DnjSjmYXbvqryZ02O12k,65348
45
45
  moleditpy/modules/template_preview_item.py,sha256=djdq3tz73d_fJGOvai3E-V9Hk9q9ZW7skx7BV59mooA,6556
46
46
  moleditpy/modules/template_preview_view.py,sha256=4OCHZDO51BvJpKdfrBWJ4_4WfLfFSKxsVIyf7I-Kj2E,3350
@@ -51,9 +51,9 @@ moleditpy/modules/assets/file_icon.ico,sha256=yyVj084A7HuMNbV073cE_Ag3Ne405qgOP3
51
51
  moleditpy/modules/assets/icon.icns,sha256=wD5R6-Vw7K662tVKhu2E1ImN0oUuyAP4youesEQsn9c,139863
52
52
  moleditpy/modules/assets/icon.ico,sha256=RfgFcx7-dHY_2STdsOQCQziY5SNhDr3gPnjO6jzEDPI,147975
53
53
  moleditpy/modules/assets/icon.png,sha256=kCFN1WacYIdy0GN6SFEbNA00ef39pCczBnFdkkBI8Bs,147110
54
- moleditpy-2.2.6.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
55
- moleditpy-2.2.6.dist-info/METADATA,sha256=vHsKbmtTEC9mSX2KC3VMASZqxNyvRpQCblso_8fBiN8,60629
56
- moleditpy-2.2.6.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
57
- moleditpy-2.2.6.dist-info/entry_points.txt,sha256=yH1h9JjALhok1foXT3-hYrC4ufoZt8b7oiBcsdnGNNM,54
58
- moleditpy-2.2.6.dist-info/top_level.txt,sha256=ARICrS4ihlPXqywlKl6o-oJa3Qz3gZRWu_VZsQ3_c44,10
59
- moleditpy-2.2.6.dist-info/RECORD,,
54
+ moleditpy-2.3.0.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
55
+ moleditpy-2.3.0.dist-info/METADATA,sha256=KMJCk7r-QqD1yc1YHvb-EoQ65q5Xgfa9gvuO0sSmzEw,60629
56
+ moleditpy-2.3.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
57
+ moleditpy-2.3.0.dist-info/entry_points.txt,sha256=yH1h9JjALhok1foXT3-hYrC4ufoZt8b7oiBcsdnGNNM,54
58
+ moleditpy-2.3.0.dist-info/top_level.txt,sha256=ARICrS4ihlPXqywlKl6o-oJa3Qz3gZRWu_VZsQ3_c44,10
59
+ moleditpy-2.3.0.dist-info/RECORD,,