MoleditPy-linux 4.11.2__tar.gz → 4.11.3__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 (93) hide show
  1. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/PKG-INFO +1 -1
  2. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/pyproject.toml +1 -1
  3. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/MoleditPy_linux.egg-info/PKG-INFO +1 -1
  4. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/core/molecular_data.py +10 -7
  5. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/plugins/plugin_manager.py +89 -56
  6. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/compute_logic.py +17 -5
  7. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/edit_actions_logic.py +9 -1
  8. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/io_logic.py +41 -6
  9. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/LICENSE +0 -0
  10. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/README.md +0 -0
  11. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/setup.cfg +0 -0
  12. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/MoleditPy_linux.egg-info/SOURCES.txt +0 -0
  13. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/MoleditPy_linux.egg-info/dependency_links.txt +0 -0
  14. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/MoleditPy_linux.egg-info/entry_points.txt +0 -0
  15. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/MoleditPy_linux.egg-info/requires.txt +0 -0
  16. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/MoleditPy_linux.egg-info/top_level.txt +0 -0
  17. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/__init__.py +0 -0
  18. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/__main__.py +0 -0
  19. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/assets/file_icon.ico +0 -0
  20. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/assets/icon.icns +0 -0
  21. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/assets/icon.ico +0 -0
  22. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/assets/icon.png +0 -0
  23. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/core/__init__.py +0 -0
  24. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/core/mol_geometry.py +0 -0
  25. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/core/stereo_check.py +0 -0
  26. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/main.py +0 -0
  27. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/plugins/__init__.py +0 -0
  28. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/plugins/plugin_interface.py +0 -0
  29. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/plugins/plugin_manager_window.py +0 -0
  30. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/__init__.py +0 -0
  31. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/about_dialog.py +0 -0
  32. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/align_plane_dialog.py +0 -0
  33. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/alignment_dialog.py +0 -0
  34. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/analysis_window.py +0 -0
  35. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/angle_dialog.py +0 -0
  36. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/app_state.py +0 -0
  37. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/atom_item.py +0 -0
  38. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/atom_picking.py +0 -0
  39. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/base_picking_dialog.py +0 -0
  40. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/bond_item.py +0 -0
  41. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/bond_length_dialog.py +0 -0
  42. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/calculation_worker.py +0 -0
  43. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/chain_mixin.py +0 -0
  44. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/chirality_warning_dialog.py +0 -0
  45. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/color_settings_dialog.py +0 -0
  46. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/constrained_optimization_dialog.py +0 -0
  47. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/custom_interactor_style.py +0 -0
  48. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/custom_qt_interactor.py +0 -0
  49. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/dialog_3d_picking_mixin.py +0 -0
  50. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/dialog_logic.py +0 -0
  51. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/dihedral_dialog.py +0 -0
  52. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/edit_3d_logic.py +0 -0
  53. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/export_logic.py +0 -0
  54. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/geometry_base_dialog.py +0 -0
  55. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/main_window.py +0 -0
  56. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/main_window_init.py +0 -0
  57. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/mirror_dialog.py +0 -0
  58. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/molecular_scene_handler.py +0 -0
  59. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/molecule_scene.py +0 -0
  60. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/move_dialog_mixin.py +0 -0
  61. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/move_group_dialog.py +0 -0
  62. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/move_selected_atoms_dialog.py +0 -0
  63. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/periodic_table_dialog.py +0 -0
  64. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/planarize_dialog.py +0 -0
  65. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/plugin_menu_manager.py +0 -0
  66. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/preview_molecule.py +0 -0
  67. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/sdf_record_dialog.py +0 -0
  68. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/settings_dialog.py +0 -0
  69. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/settings_tabs/__init__.py +0 -0
  70. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/settings_tabs/settings_2d_cleanup_tab.py +0 -0
  71. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/settings_tabs/settings_2d_tab.py +0 -0
  72. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/settings_tabs/settings_3d_tabs.py +0 -0
  73. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/settings_tabs/settings_labels_tab.py +0 -0
  74. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/settings_tabs/settings_other_tab.py +0 -0
  75. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/settings_tabs/settings_tab_base.py +0 -0
  76. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/string_importers.py +0 -0
  77. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/template_preview_item.py +0 -0
  78. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/template_preview_view.py +0 -0
  79. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/translation_dialog.py +0 -0
  80. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/ui_manager.py +0 -0
  81. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/user_template_dialog.py +0 -0
  82. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/view_3d_logic.py +0 -0
  83. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/ui/zoomable_view.py +0 -0
  84. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/utils/__init__.py +0 -0
  85. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/utils/constants.py +0 -0
  86. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/utils/default_settings.py +0 -0
  87. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/utils/finite_float.py +0 -0
  88. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/utils/hit_radius.py +0 -0
  89. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/utils/label_style.py +0 -0
  90. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/utils/sdf_records.py +0 -0
  91. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/utils/sip_isdeleted_safe.py +0 -0
  92. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/utils/suppress_log.py +0 -0
  93. {moleditpy_linux-4.11.2 → moleditpy_linux-4.11.3}/src/moleditpy_linux/utils/system_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: MoleditPy-linux
3
- Version: 4.11.2
3
+ Version: 4.11.3
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
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
5
5
  [project]
6
6
  name = "MoleditPy-linux"
7
7
 
8
- version = "4.11.2"
8
+ version = "4.11.3"
9
9
 
10
10
  license = {file = "LICENSE"}
11
11
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: MoleditPy-linux
3
- Version: 4.11.2
3
+ Version: 4.11.3
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
@@ -438,13 +438,16 @@ class MolecularData:
438
438
  bond.SetStereo(Chem.BondStereo.STEREOE)
439
439
  ez_wanted[bond_idx] = "Z" if stereo_type == 3 else "E"
440
440
 
441
- # Clear BondDir (wedge/dash) of adjacent single bonds assigned via coordinates to avoid label conflicts
442
- b1 = final_mol.GetBondBetweenAtoms(begin_atom_idx, neigh1_idx)
443
- b2 = final_mol.GetBondBetweenAtoms(end_atom_idx, neigh2_idx)
444
- if b1 is not None:
445
- b1.SetBondDir(Chem.BondDir.NONE)
446
- if b2 is not None:
447
- b2.SetBondDir(Chem.BondDir.NONE)
441
+ # Preserve explicit wedge/dash labels, including those applied later.
442
+ for center_idx, neighbor_idx in (
443
+ (begin_atom_idx, neigh1_idx),
444
+ (end_atom_idx, neigh2_idx),
445
+ ):
446
+ adjacent = final_mol.GetBondBetweenAtoms(center_idx, neighbor_idx)
447
+ if adjacent is not None and bond_stereo_info.get(
448
+ adjacent.GetIdx(), {}
449
+ ).get("type") not in (1, 2):
450
+ adjacent.SetBondDir(Chem.BondDir.NONE)
448
451
 
449
452
  # The labels are CIP E/Z; the neighbors picked above are not always
450
453
  # the CIP-higher ones.
@@ -16,8 +16,10 @@ import hashlib
16
16
  import importlib.util
17
17
  import json
18
18
  import logging
19
+ import ntpath
19
20
  import os
20
21
  import shutil
22
+ import stat
21
23
  import sys
22
24
  import zipfile
23
25
  from typing import Any, Callable, Dict, List, Optional, Tuple
@@ -164,6 +166,88 @@ class PluginManager:
164
166
  self.ensure_plugin_dir()
165
167
  QDesktopServices.openUrl(QUrl.fromLocalFile(self.plugin_dir))
166
168
 
169
+ @staticmethod
170
+ def _safe_install_path(root: str, name: str) -> str:
171
+ """Resolve a portable relative path strictly within an install directory."""
172
+ normalized = name.replace("\\", "/").rstrip("/")
173
+ parts = normalized.split("/")
174
+ reserved = {"CON", "PRN", "AUX", "NUL"} | {
175
+ f"{prefix}{number}" for prefix in ("COM", "LPT") for number in range(1, 10)
176
+ }
177
+ if (
178
+ not normalized
179
+ or normalized.startswith("/")
180
+ or ntpath.splitdrive(normalized)[0]
181
+ or any(
182
+ part in ("", ".", "..")
183
+ or part.endswith((".", " "))
184
+ or any(char in part for char in '<>:"|?*\0')
185
+ or part.split(".")[0].upper() in reserved
186
+ for part in parts
187
+ )
188
+ ):
189
+ raise ValueError(f"Unsafe plugin path: {name!r}")
190
+ destination = os.path.abspath(os.path.join(root, *parts))
191
+ resolved_root = os.path.normcase(os.path.realpath(root))
192
+ resolved_destination = os.path.normcase(os.path.realpath(destination))
193
+ if (
194
+ resolved_destination == resolved_root
195
+ or os.path.commonpath([resolved_root, resolved_destination])
196
+ != resolved_root
197
+ ):
198
+ raise ValueError(f"Plugin path escapes install directory: {name!r}")
199
+ return destination
200
+
201
+ def _install_zip(self, file_path: str, filename: str) -> str:
202
+ """Validate every archive member before removing or writing plugin files."""
203
+ with zipfile.ZipFile(file_path, "r") as archive:
204
+ members = []
205
+ for member in archive.infolist():
206
+ self._safe_install_path(self.plugin_dir, member.orig_filename)
207
+ if stat.S_ISLNK(member.external_attr >> 16):
208
+ raise ValueError(
209
+ "Plugin ZIP archives cannot contain symbolic links"
210
+ )
211
+ members.append((member, member.filename.replace("\\", "/")))
212
+ if not members:
213
+ raise ValueError("Plugin ZIP archive is empty")
214
+
215
+ roots = {name.split("/")[0] for _, name in members}
216
+ nested = (
217
+ len(roots) == 1
218
+ and any("/" in name for _, name in members)
219
+ and next(iter(roots)) != "__init__.py"
220
+ )
221
+ folder = next(iter(roots)) if nested else os.path.splitext(filename)[0]
222
+ destination = self._safe_install_path(self.plugin_dir, folder)
223
+ relative_members = [
224
+ (member, name.partition("/")[2] if nested else name)
225
+ for member, name in members
226
+ ]
227
+ # Validate against the package boundary as well as the plugin root.
228
+ for _, name in relative_members:
229
+ if name:
230
+ self._safe_install_path(destination, name)
231
+
232
+ if os.path.exists(destination):
233
+ if os.path.isdir(destination):
234
+ shutil.rmtree(destination)
235
+ else:
236
+ os.remove(destination)
237
+ os.makedirs(destination)
238
+ for member, name in relative_members:
239
+ if not name:
240
+ continue
241
+ target = self._safe_install_path(destination, name)
242
+ if name.endswith("/"):
243
+ os.makedirs(target, exist_ok=True)
244
+ else:
245
+ os.makedirs(os.path.dirname(target), exist_ok=True)
246
+ with archive.open(member) as source, open(target, "wb") as output:
247
+ shutil.copyfileobj(source, output)
248
+ kind = "ZIP" if nested else "Flat ZIP"
249
+ return f"Installed package {folder} (from {kind})"
250
+
167
251
  def install_plugin(self, file_path: str) -> Tuple[bool, str]:
168
252
  """Copies a plugin file to the plugin directory. Supports .py and .zip."""
169
253
  self.ensure_plugin_dir()
@@ -174,7 +258,7 @@ class PluginManager:
174
258
 
175
259
  if os.path.isdir(file_path):
176
260
  # Copy entire directory
177
- dest_path = os.path.join(self.plugin_dir, filename)
261
+ dest_path = self._safe_install_path(self.plugin_dir, filename)
178
262
  if os.path.exists(dest_path):
179
263
  # Option 1: Overwrite (remove then copy) - safer for clean install
180
264
  if os.path.isdir(dest_path):
@@ -190,63 +274,10 @@ class PluginManager:
190
274
  )
191
275
  msg = f"Installed package {filename}"
192
276
  elif filename.lower().endswith(".zip"):
193
- # Extract ZIP contents
194
- with zipfile.ZipFile(file_path, "r") as zf:
195
- # Smart Extraction: Check if ZIP has a single top-level folder
196
- # Fix for paths with backslashes on Windows if zip was created on Windows
197
- roots = set()
198
- root_is_dir = False
199
- for name in zf.namelist():
200
- # Normalize path separators to forward slash for consistent check
201
- name = name.replace("\\", "/")
202
- parts = name.split("/")
203
- if parts[0]:
204
- roots.add(parts[0])
205
- # Sub-entries prove the root is a folder
206
- if len(parts) > 1:
207
- root_is_dir = True
208
-
209
- is_nested = len(roots) == 1 and root_is_dir
210
-
211
- if is_nested:
212
- # Case A: ZIP contains a single folder (e.g. MyPlugin/init.py)
213
- top_folder = list(roots)[0]
214
-
215
- # A root named __init__.py always needs a wrapper folder
216
- if top_folder == "__init__.py":
217
- is_nested = False
218
-
219
- if is_nested:
220
- # Case A (Confirmed): Extract directly
221
- dest_path = os.path.join(self.plugin_dir, top_folder)
222
-
223
- # Clean Install: Remove existing folder to prevent stale files
224
- if os.path.exists(dest_path):
225
- if os.path.isdir(dest_path):
226
- shutil.rmtree(dest_path)
227
- else:
228
- os.remove(dest_path)
229
-
230
- zf.extractall(self.plugin_dir)
231
- msg = f"Installed package {top_folder} (from ZIP)"
232
- else:
233
- # Case B: ZIP is flat (e.g. file1.py, file2.py or just __init__.py)
234
- # Extract into a new folder named after the ZIP file
235
- folder_name = os.path.splitext(filename)[0]
236
- dest_path = os.path.join(self.plugin_dir, folder_name)
237
-
238
- if os.path.exists(dest_path):
239
- if os.path.isdir(dest_path):
240
- shutil.rmtree(dest_path)
241
- else:
242
- os.remove(dest_path)
243
-
244
- os.makedirs(dest_path)
245
- zf.extractall(dest_path)
246
- msg = f"Installed package {folder_name} (from Flat ZIP)"
277
+ msg = self._install_zip(file_path, filename)
247
278
  else:
248
279
  # Standard file copy
249
- dest_path = os.path.join(self.plugin_dir, filename)
280
+ dest_path = self._safe_install_path(self.plugin_dir, filename)
250
281
  if os.path.exists(dest_path):
251
282
  if os.path.isdir(dest_path):
252
283
  shutil.rmtree(dest_path)
@@ -265,7 +296,9 @@ class PluginManager:
265
296
  OSError,
266
297
  ImportError,
267
298
  SyntaxError,
299
+ zipfile.BadZipFile,
268
300
  ) as e:
301
+ logging.warning("Plugin installation failed: %s", e, exc_info=True)
269
302
  return False, str(e)
270
303
 
271
304
  def discover_plugins(self, parent: Any = None) -> List[Dict[str, Any]]:
@@ -58,6 +58,7 @@ class ComputeManager:
58
58
  """Initialize ComputeManager with reference to host window."""
59
59
  self._calculating_text_actor: Any = None
60
60
  self.original_atom_properties: Dict[int, int] = {}
61
+ self._worker_atom_properties: Dict[int, Dict[int, int]] = {}
61
62
  self.host = host
62
63
  self.last_successful_optimization_method: Optional[str] = None
63
64
  self._active_calc_threads: List[QThread] = []
@@ -270,6 +271,7 @@ class ComputeManager:
270
271
  self.next_conversion_id = run_id + 1
271
272
  self.active_worker_ids.add(run_id)
272
273
  self._conversion_run_ids.add(run_id)
274
+ self._worker_atom_properties[run_id] = self.original_atom_properties.copy()
273
275
 
274
276
  # The worker only runs built-in force fields. If the selected method is a
275
277
  # plugin optimizer, pre-optimize with MMFF (RDKit) in the worker (which
@@ -336,6 +338,7 @@ class ComputeManager:
336
338
  self.halt_ids.update(wids_to_halt)
337
339
  self.active_worker_ids.clear()
338
340
  self._pending_plugin_opt.clear()
341
+ self._worker_atom_properties.clear()
339
342
  getattr(self, "_conversion_run_ids", set()).clear()
340
343
 
341
344
  self._restore_button_ui()
@@ -394,6 +397,11 @@ class ComputeManager:
394
397
  run_id = int(getattr(self, "next_conversion_id", 1))
395
398
  self.next_conversion_id = run_id + 1
396
399
  options["worker_id"] = run_id
400
+ self._worker_atom_properties[run_id] = {
401
+ atom.GetIdx(): atom.GetIntProp("_original_atom_id")
402
+ for atom in self.host.view_3d_manager.current_mol.GetAtoms()
403
+ if atom.HasProp("_original_atom_id")
404
+ }
397
405
 
398
406
  self.active_worker_ids.add(run_id)
399
407
 
@@ -601,9 +609,14 @@ class ComputeManager:
601
609
  self.host.set_current_molecule(mol)
602
610
  self.host.is_xyz_derived = False
603
611
 
604
- # Restore properties
612
+ # MOL blocks omit custom atom properties; restore the submitting molecule's IDs.
613
+ atom_properties = (
614
+ self._worker_atom_properties.pop(worker_id, {})
615
+ if worker_id is not None
616
+ else self.original_atom_properties
617
+ )
605
618
  if mol:
606
- for i, orig_id in self.original_atom_properties.items():
619
+ for i, orig_id in atom_properties.items():
607
620
  if i < mol.GetNumAtoms():
608
621
  mol.GetAtomWithIdx(i).SetIntProp("_original_atom_id", orig_id)
609
622
 
@@ -713,11 +726,10 @@ class ComputeManager:
713
726
  # Accept either a string or (worker_id, message) tuple from the worker signal
714
727
  if isinstance(message, tuple) and len(message) == 2:
715
728
  worker_id, msg = message
729
+ self._worker_atom_properties.pop(worker_id, None)
716
730
  getattr(self, "_conversion_run_ids", set()).discard(worker_id)
717
731
  if worker_id not in self.active_worker_ids:
718
- # Still cleanup overlay/buttons even if stale
719
- self._remove_calculating_text()
720
- self._restore_button_ui()
732
+ # A stale worker must not reset the current calculation's UI.
721
733
  # If it was a halt or error from a stale worker, show with ID
722
734
  if msg == "Halt" or msg == "Halted":
723
735
  self.host.statusBar().showMessage( # type: ignore[union-attr]
@@ -93,7 +93,7 @@ class Rotate2DDialog(QDialog):
93
93
 
94
94
  # Slider
95
95
  self.slider = QSlider(Qt.Orientation.Horizontal)
96
- self.slider.setRange(-180, 180)
96
+ self.slider.setRange(-360, 360)
97
97
  self.slider.setValue(int(initial_angle))
98
98
  self.slider.setTickPosition(QSlider.TickPosition.TicksBelow)
99
99
  self.slider.setTickInterval(15)
@@ -240,6 +240,9 @@ class EditActionsManager:
240
240
  finally:
241
241
  self.host.is_restoring_state = False
242
242
 
243
+ self.host.set_has_unsaved_changes(True)
244
+ self.host.state_manager.update_window_title()
245
+
243
246
  # Re-evaluate menu states based on 3D structure after Undo
244
247
  if (
245
248
  self.host.view_3d_manager.current_mol
@@ -265,6 +268,9 @@ class EditActionsManager:
265
268
  finally:
266
269
  self.host.is_restoring_state = False
267
270
 
271
+ self.host.set_has_unsaved_changes(True)
272
+ self.host.state_manager.update_window_title()
273
+
268
274
  # Re-evaluate menu states based on 3D structure after Redo
269
275
  if (
270
276
  self.host.view_3d_manager.current_mol
@@ -954,6 +960,8 @@ class EditActionsManager:
954
960
  # Cancel if requested
955
961
  return False
956
962
 
963
+ # Invalidate outstanding results before replacing the document.
964
+ self.host.compute_manager.halt_conversion()
957
965
  self.host.ui_manager.restore_ui_for_editing()
958
966
 
959
967
  # Reset 3D mode
@@ -16,6 +16,8 @@ import logging
16
16
  import os
17
17
  import json
18
18
  import pickle
19
+ import stat
20
+ import tempfile
19
21
  import unicodedata
20
22
  from ..utils.suppress_log import suppress_log
21
23
  from typing import Any, Callable, List, Optional, Tuple
@@ -606,6 +608,42 @@ class IOManager:
606
608
 
607
609
  return len(bonds_added)
608
610
 
611
+ @staticmethod
612
+ def _write_project_json(file_path: str, data: dict[str, Any]) -> None:
613
+ """Replace a project only after its complete JSON has been written."""
614
+ # Resolve symlinks so os.replace updates the linked project rather
615
+ # than swapping the link itself for a regular file.
616
+ destination = os.path.realpath(file_path)
617
+ try:
618
+ existing_mode = stat.S_IMODE(os.stat(destination).st_mode)
619
+ except FileNotFoundError:
620
+ existing_mode = None
621
+ temporary_path = None
622
+ try:
623
+ with tempfile.NamedTemporaryFile(
624
+ mode="w",
625
+ encoding="utf-8",
626
+ dir=os.path.dirname(destination),
627
+ prefix=f".{os.path.basename(destination)}.",
628
+ suffix=".tmp",
629
+ delete=False,
630
+ ) as stream:
631
+ temporary_path = stream.name
632
+ json.dump(data, stream, indent=2, ensure_ascii=False)
633
+ stream.flush()
634
+ os.fsync(stream.fileno())
635
+ if existing_mode is not None:
636
+ # NamedTemporaryFile is owner-only; keep the project's access.
637
+ os.chmod(temporary_path, existing_mode)
638
+ os.replace(temporary_path, destination)
639
+ temporary_path = None
640
+ finally:
641
+ if temporary_path is not None:
642
+ try:
643
+ os.unlink(temporary_path)
644
+ except OSError:
645
+ logging.exception("Failed to remove temporary project file")
646
+
609
647
  def save_project(self) -> None:
610
648
  """Save (Ctrl+S) - Defaults to PMEPRJ format."""
611
649
  if (
@@ -627,8 +665,7 @@ class IOManager:
627
665
  if current_path and current_path.lower().endswith(".pmeprj"):
628
666
  try:
629
667
  json_data = self.host.state_manager.create_json_data()
630
- with open(current_path, "w", encoding="utf-8") as f:
631
- json.dump(json_data, f, indent=2, ensure_ascii=False)
668
+ self._write_project_json(current_path, json_data)
632
669
 
633
670
  self.host.set_has_unsaved_changes(False)
634
671
  self.host.state_manager.update_window_title()
@@ -669,8 +706,7 @@ class IOManager:
669
706
 
670
707
  try:
671
708
  json_data = self.host.state_manager.create_json_data()
672
- with open(file_path, "w", encoding="utf-8") as f:
673
- json.dump(json_data, f, indent=2, ensure_ascii=False)
709
+ self._write_project_json(file_path, json_data)
674
710
 
675
711
  self.host.set_has_unsaved_changes(False)
676
712
  self.host.set_current_file_path(file_path)
@@ -740,8 +776,7 @@ class IOManager:
740
776
  file_path += ".pmeprj"
741
777
 
742
778
  json_data = self.host.state_manager.create_json_data()
743
- with open(file_path, "w", encoding="utf-8") as f:
744
- json.dump(json_data, f, indent=2, ensure_ascii=False)
779
+ self._write_project_json(file_path, json_data)
745
780
 
746
781
  self.host.set_has_unsaved_changes(False)
747
782
  self.host.set_current_file_path(file_path)