MoleditPy-linux 4.2.3__tar.gz → 4.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 (81) hide show
  1. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/PKG-INFO +1 -1
  2. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/pyproject.toml +1 -1
  3. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/MoleditPy_linux.egg-info/PKG-INFO +1 -1
  4. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/main.py +123 -4
  5. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/plugins/plugin_manager.py +16 -7
  6. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/plugins/plugin_manager_window.py +2 -3
  7. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/ui/align_plane_dialog.py +2 -3
  8. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/ui/alignment_dialog.py +1 -2
  9. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/ui/app_state.py +13 -5
  10. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/ui/bond_item.py +2 -2
  11. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/ui/compute_logic.py +6 -3
  12. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/ui/constrained_optimization_dialog.py +5 -8
  13. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/ui/custom_interactor_style.py +15 -13
  14. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/ui/dialog_3d_picking_mixin.py +1 -1
  15. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/ui/dialog_logic.py +2 -3
  16. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/ui/edit_actions_logic.py +1 -1
  17. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/ui/main_window.py +1 -1
  18. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/ui/main_window_init.py +6 -5
  19. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/ui/mirror_dialog.py +2 -4
  20. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/ui/molecular_scene_handler.py +7 -7
  21. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/ui/molecule_scene.py +3 -3
  22. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/ui/move_group_dialog.py +1 -1
  23. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/ui/move_selected_atoms_dialog.py +1 -1
  24. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/ui/planarize_dialog.py +2 -2
  25. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/ui/plugin_menu_manager.py +70 -52
  26. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/ui/template_preview_view.py +2 -2
  27. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/ui/translation_dialog.py +1 -1
  28. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/ui/ui_manager.py +6 -1
  29. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/ui/user_template_dialog.py +8 -10
  30. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/ui/view_3d_logic.py +17 -13
  31. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/LICENSE +0 -0
  32. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/README.md +0 -0
  33. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/setup.cfg +0 -0
  34. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/MoleditPy_linux.egg-info/SOURCES.txt +0 -0
  35. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/MoleditPy_linux.egg-info/dependency_links.txt +0 -0
  36. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/MoleditPy_linux.egg-info/entry_points.txt +0 -0
  37. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/MoleditPy_linux.egg-info/requires.txt +0 -0
  38. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/MoleditPy_linux.egg-info/top_level.txt +0 -0
  39. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/__init__.py +0 -0
  40. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/__main__.py +0 -0
  41. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/assets/file_icon.ico +0 -0
  42. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/assets/icon.icns +0 -0
  43. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/assets/icon.ico +0 -0
  44. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/assets/icon.png +0 -0
  45. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/core/__init__.py +0 -0
  46. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/core/mol_geometry.py +0 -0
  47. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/core/molecular_data.py +0 -0
  48. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/plugins/__init__.py +0 -0
  49. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/plugins/plugin_interface.py +0 -0
  50. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/ui/__init__.py +0 -0
  51. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/ui/about_dialog.py +0 -0
  52. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/ui/analysis_window.py +0 -0
  53. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/ui/angle_dialog.py +0 -0
  54. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/ui/atom_item.py +0 -0
  55. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/ui/atom_picking.py +0 -0
  56. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/ui/base_picking_dialog.py +0 -0
  57. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/ui/bond_length_dialog.py +0 -0
  58. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/ui/calculation_worker.py +0 -0
  59. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/ui/color_settings_dialog.py +0 -0
  60. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/ui/custom_qt_interactor.py +0 -0
  61. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/ui/dihedral_dialog.py +0 -0
  62. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/ui/edit_3d_logic.py +0 -0
  63. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/ui/export_logic.py +0 -0
  64. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/ui/geometry_base_dialog.py +0 -0
  65. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/ui/io_logic.py +0 -0
  66. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/ui/periodic_table_dialog.py +0 -0
  67. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/ui/settings_dialog.py +0 -0
  68. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/ui/settings_tabs/__init__.py +0 -0
  69. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/ui/settings_tabs/settings_2d_tab.py +0 -0
  70. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/ui/settings_tabs/settings_3d_tabs.py +0 -0
  71. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/ui/settings_tabs/settings_other_tab.py +0 -0
  72. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/ui/settings_tabs/settings_tab_base.py +0 -0
  73. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/ui/string_importers.py +0 -0
  74. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/ui/template_preview_item.py +0 -0
  75. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/ui/zoomable_view.py +0 -0
  76. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/utils/__init__.py +0 -0
  77. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/utils/constants.py +0 -0
  78. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/utils/default_settings.py +0 -0
  79. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/utils/sip_isdeleted_safe.py +0 -0
  80. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/src/moleditpy_linux/utils/suppress_log.py +0 -0
  81. {moleditpy_linux-4.2.3 → moleditpy_linux-4.3.0}/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.2.3
3
+ Version: 4.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
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
5
5
  [project]
6
6
  name = "MoleditPy-linux"
7
7
 
8
- version = "4.2.3"
8
+ version = "4.3.0"
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.2.3
3
+ Version: 4.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
@@ -11,20 +11,23 @@ DOI: 10.5281/zenodo.17268532
11
11
  """
12
12
 
13
13
  import ctypes
14
+ import hashlib
14
15
  import json
15
16
  import logging
16
17
  import logging.handlers
17
18
  import os
18
19
  import sys
19
20
  import argparse
20
- from typing import Any, Optional
21
+ import time
22
+ import traceback
23
+ from typing import Any, Dict, Optional
21
24
 
22
25
  from .utils.constants import VERSION
23
26
 
24
27
  # VERSION is resolved above (before Qt) so --version works without launching the app.
25
28
 
26
- from PyQt6.QtCore import QtMsgType, qInstallMessageHandler
27
- from PyQt6.QtWidgets import QApplication
29
+ from PyQt6.QtCore import QtMsgType, QThread, QTimer, qInstallMessageHandler
30
+ from PyQt6.QtWidgets import QApplication, QMessageBox
28
31
 
29
32
  from .ui.main_window import MainWindow
30
33
 
@@ -49,6 +52,109 @@ def _qt_message_handler(mode: QtMsgType, _context: Any, message: Optional[str])
49
52
  logging.log(_QT_LOG_LEVEL.get(mode, logging.WARNING), "Qt: %s", message)
50
53
 
51
54
 
55
+ class _ErrorDialogHandler(logging.Handler):
56
+ """Surface ERROR/CRITICAL log records in a dialog, once per unique message.
57
+
58
+ The uncaught-exception hook routes through here too (it logs at ERROR with
59
+ ``exc_info``), so every genuine failure — logged explicitly or bubbling out
60
+ of Qt — gets one dialog. Warnings/info never reach it (handler level is
61
+ ERROR). All state is per-instance; the one instance is created and attached
62
+ in :func:`setup_logging` (only for a real GUI run, never headless).
63
+
64
+ Guards: GUI thread + live QApplication only (a worker-thread record, where a
65
+ QMessageBox is unsafe, stays log-only); a time-windowed dedup so a fast
66
+ repeating error (e.g. from a QTimer slot) collapses to one dialog while a
67
+ genuine retry seconds later surfaces again; and ``extra={"no_dialog": True}``
68
+ to opt a record out. ``emit`` never raises.
69
+
70
+ The dialog is shown non-blocking (``show``, not ``exec``): it never spins a
71
+ nested event loop, so it cannot freeze the app — or a GUI-enabled test run —
72
+ while it is on screen.
73
+ """
74
+
75
+ # Identical errors within this many seconds share one dialog; a repeat
76
+ # after it surfaces again.
77
+ _DEDUP_WINDOW_S = 10.0
78
+
79
+ def __init__(self, log_path: Optional[str] = None) -> None:
80
+ super().__init__(level=logging.ERROR)
81
+ # signature -> monotonic timestamp of its last shown dialog.
82
+ self._last_shown: Dict[str, float] = {}
83
+ # Held so a non-blocking box is not garbage-collected before it closes.
84
+ self._open_boxes: set = set()
85
+ self._log_path = log_path
86
+
87
+ def emit(self, record: logging.LogRecord) -> None:
88
+ try:
89
+ if getattr(record, "no_dialog", False):
90
+ return
91
+ app = QApplication.instance()
92
+ if app is None or QThread.currentThread() is not app.thread():
93
+ return
94
+ if app.property("moleditpy_shutting_down"):
95
+ return
96
+
97
+ message = record.getMessage()
98
+ # Source location (as the terminal log shows), then any traceback.
99
+ detail = f"{record.pathname}:{record.lineno}"
100
+ if record.exc_info:
101
+ detail += "\n\n" + "".join(traceback.format_exception(*record.exc_info))
102
+
103
+ signature = hashlib.sha1(
104
+ (message + detail).encode("utf-8", "replace")
105
+ ).hexdigest()
106
+ now = time.monotonic()
107
+ last = self._last_shown.get(signature)
108
+ if last is not None and now - last < self._DEDUP_WINDOW_S:
109
+ return
110
+ self._last_shown[signature] = now
111
+
112
+ # Defer so a site's own QMessageBox.critical (raised right after the
113
+ # log call) is already modal when _show checks activeModalWidget.
114
+ QTimer.singleShot(0, lambda: self._show(message, detail))
115
+ except Exception: # pragma: no cover - logging must never raise
116
+ self.handleError(record)
117
+
118
+ def _details(self) -> str:
119
+ """Where to find the full error text, honest about file logging."""
120
+ if self._log_path:
121
+ return (
122
+ "Full details are in the terminal output and the log file:\n"
123
+ f"{self._log_path}"
124
+ )
125
+ return (
126
+ "Full details are in the terminal output. Enable "
127
+ "Settings ▸ Other ▸ 'Save log to file' to also keep them "
128
+ "on disk."
129
+ )
130
+
131
+ def _show(self, message: str, detail: str) -> None:
132
+ """Show one non-blocking error dialog, unless another modal is up.
133
+
134
+ Sites (and plugins) log the error and then immediately raise their own
135
+ ``QMessageBox.critical``; by the time this deferred call fires, that
136
+ modal is up, so we detect it and stay quiet — surfacing the generic
137
+ dialog only for errors nobody else reports.
138
+ """
139
+ if QApplication.activeModalWidget() is not None:
140
+ return
141
+ try:
142
+ box = QMessageBox()
143
+ box.setIcon(QMessageBox.Icon.Critical)
144
+ box.setWindowTitle("MoleditPy — Error")
145
+ box.setText(message or "An unexpected error occurred.")
146
+ box.setInformativeText(self._details())
147
+ if detail:
148
+ box.setDetailedText(detail)
149
+ box.setStandardButtons(QMessageBox.StandardButton.Ok)
150
+ box.setModal(True)
151
+ self._open_boxes.add(box)
152
+ box.finished.connect(lambda _result, b=box: self._open_boxes.discard(b))
153
+ box.show()
154
+ except Exception:
155
+ logging.debug("Failed to show error dialog", exc_info=True)
156
+
157
+
52
158
  def _read_startup_log_settings() -> tuple[bool, bool]:
53
159
  """Read log_to_file and log_level_debug from settings.json before Qt starts.
54
160
 
@@ -78,6 +184,7 @@ def setup_logging() -> None:
78
184
  force=True,
79
185
  )
80
186
 
187
+ active_log_path: Optional[str] = None
81
188
  if log_to_file:
82
189
  log_dir = os.path.join(os.path.expanduser("~"), ".moleditpy")
83
190
  try:
@@ -90,11 +197,23 @@ def setup_logging() -> None:
90
197
  fh.setFormatter(logging.Formatter(fmt))
91
198
  logging.getLogger().addHandler(fh)
92
199
  logging.info("File logging enabled: %s", log_path)
200
+ active_log_path = log_path
93
201
  except OSError as e:
94
202
  logging.warning("Could not open log file: %s", e)
95
203
 
204
+ # Surface every ERROR/CRITICAL record in a dialog (see _ErrorDialogHandler);
205
+ # uncaught exceptions flow through here too via handle_exception's log call.
206
+ # It is a GUI feature, so it is skipped in headless mode (MOLEDITPY_HEADLESS),
207
+ # where a blocking modal would hang the run.
208
+ if not os.environ.get("MOLEDITPY_HEADLESS"):
209
+ logging.getLogger().addHandler(_ErrorDialogHandler(log_path=active_log_path))
210
+
96
211
  def handle_exception(exc_type: Any, exc_value: Any, exc_traceback: Any) -> None:
97
- """Log unhandled exceptions using the configured logging system."""
212
+ """Log unhandled exceptions; the ERROR dialog handler surfaces them.
213
+
214
+ The dialog fires only for genuine failures — this log call and explicit
215
+ logging.error/critical calls — never for warnings/info.
216
+ """
98
217
  if issubclass(exc_type, KeyboardInterrupt):
99
218
  sys.__excepthook__(exc_type, exc_value, exc_traceback)
100
219
  return
@@ -113,7 +113,7 @@ class PluginManager:
113
113
  try:
114
114
  os.makedirs(self.plugin_dir)
115
115
  except OSError as e:
116
- logging.error(f"Error creating plugin directory: {e}")
116
+ logging.warning(f"Error creating plugin directory: {e}")
117
117
 
118
118
  def open_plugin_folder(self) -> None:
119
119
  """Opens the plugin directory in the OS file explorer."""
@@ -381,7 +381,9 @@ class PluginManager:
381
381
  module.initialize(context)
382
382
  except Exception as e: # plugins have full app access; catch everything to isolate faults
383
383
  status = f"Error (Init): {e}"
384
- logging.exception("Plugin %s initialize error", plugin_name)
384
+ logging.warning(
385
+ "Plugin %s initialize error", plugin_name, exc_info=True
386
+ )
385
387
  elif has_autorun:
386
388
  try:
387
389
  if self.main_window:
@@ -390,7 +392,9 @@ class PluginManager:
390
392
  status = "Skipped (No MW)"
391
393
  except Exception as e: # plugins have full app access; catch everything to isolate faults
392
394
  status = f"Error (Autorun): {e}"
393
- logging.exception("Plugin %s autorun error", plugin_name)
395
+ logging.warning(
396
+ "Plugin %s autorun error", plugin_name, exc_info=True
397
+ )
394
398
  elif not has_run:
395
399
  status = "No Entry Point"
396
400
 
@@ -409,7 +413,9 @@ class PluginManager:
409
413
  )
410
414
 
411
415
  except Exception as e: # plugins have full app access; isolate any load failure to prevent crashing discovery
412
- logging.exception("Failed to load plugin %s: %s", module_name, e)
416
+ logging.warning(
417
+ "Failed to load plugin %s: %s", module_name, e, exc_info=True
418
+ )
413
419
 
414
420
  def run_plugin(self, module: Any, main_window: Any) -> None:
415
421
  """Executes the plugin's run method (Legacy manual trigger)."""
@@ -620,7 +626,7 @@ class PluginManager:
620
626
  except (RuntimeError, ValueError, TypeError):
621
627
  continue
622
628
  except (RuntimeError, AttributeError) as e:
623
- logging.error(f"Error retrieving selected atom indices: {e}")
629
+ logging.warning(f"Error retrieving selected atom indices: {e}")
624
630
 
625
631
  return selected_indices
626
632
 
@@ -640,8 +646,11 @@ class PluginManager:
640
646
  try:
641
647
  handler["callback"]()
642
648
  except Exception as e: # plugins have full app access; catch everything to prevent data loss on document reset
643
- logging.exception(
644
- "Error in document reset handler for %s: %s", handler["plugin"], e
649
+ logging.warning(
650
+ "Error in document reset handler for %s: %s",
651
+ handler["plugin"],
652
+ e,
653
+ exc_info=True,
645
654
  )
646
655
 
647
656
  def get_plugin_info_safe(self, file_path: str) -> Dict[str, str]:
@@ -10,6 +10,7 @@ Repo: https://github.com/HiroYokoyama/python_molecular_editor
10
10
  DOI: 10.5281/zenodo.17268532
11
11
  """
12
12
 
13
+ import logging
13
14
  import os
14
15
  import shutil
15
16
  from typing import Any, Optional
@@ -213,9 +214,7 @@ class PluginManagerWindow(QDialog):
213
214
  f"Removed '{plugin.get('name', 'Unknown')}'.",
214
215
  )
215
216
  except OSError as e:
216
- QMessageBox.critical(
217
- self, "Error", f"Failed to delete plugin: {e}"
218
- )
217
+ logging.exception("Failed to delete plugin: %s", e)
219
218
  else:
220
219
  QMessageBox.warning(
221
220
  self, "Error", f"Plugin file not found:\n{filepath}"
@@ -162,7 +162,7 @@ class AlignPlaneDialog(BasePickingDialog):
162
162
  self.update_display()
163
163
 
164
164
  except (AttributeError, RuntimeError, TypeError, KeyError) as e:
165
- logging.exception("Failed to select all atoms")
165
+ logging.warning("Failed to select all atoms", exc_info=True)
166
166
  QMessageBox.warning(self, "Warning", f"Failed to select all atoms: {e}")
167
167
 
168
168
  def update_display(self) -> None:
@@ -285,5 +285,4 @@ class AlignPlaneDialog(BasePickingDialog):
285
285
  self.show_atom_labels()
286
286
 
287
287
  except (AttributeError, RuntimeError, ValueError, TypeError) as e:
288
- logging.exception("Failed to apply align")
289
- QMessageBox.critical(self, "Error", f"Failed to apply align: {str(e)}")
288
+ logging.exception("Failed to apply align: %s", e)
@@ -276,8 +276,7 @@ class AlignmentDialog(Dialog3DPickingMixin, QDialog):
276
276
  )
277
277
 
278
278
  except (AttributeError, RuntimeError, ValueError, TypeError) as e:
279
- logging.exception("Failed to apply alignment")
280
- QMessageBox.critical(self, "Error", f"Failed to apply alignment: {str(e)}")
279
+ logging.exception("Failed to apply alignment: %s", e)
281
280
 
282
281
  def closeEvent(self, event: Optional[QCloseEvent]) -> None:
283
282
  """Clean up when the dialog is closed."""
@@ -223,7 +223,11 @@ class StateManager:
223
223
  self.host.view_3d_manager.draw_molecule_3d(
224
224
  self.host.view_3d_manager.current_mol
225
225
  )
226
- if self.host.view_3d_manager.plotter:
226
+ # draw_molecule_3d already preserves the camera; only refit
227
+ # on a fresh load, not on undo/redo (which restores state).
228
+ if self.host.view_3d_manager.plotter and not getattr(
229
+ self.host, "is_restoring_state", False
230
+ ):
227
231
  self.host.view_3d_manager.plotter.reset_camera()
228
232
 
229
233
  self.host.ui_manager.enable_3d_features(True)
@@ -232,7 +236,7 @@ class StateManager:
232
236
  self.host.clear_3d_view()
233
237
  self.host.ui_manager.enable_3d_features(False)
234
238
  except (RuntimeError, ValueError, TypeError) as e:
235
- logging.error(f"Could not load 3D model from state data: {e}")
239
+ logging.warning(f"Could not load 3D model from state data: {e}")
236
240
  self.host.update_status_message(f"Error loading 3D model: {e}", 5000)
237
241
  self.host.set_current_molecule(None)
238
242
  self.host.ui_manager.enable_3d_features(False)
@@ -528,7 +532,9 @@ class StateManager:
528
532
  p_state = callback()
529
533
  plugin_data[name] = p_state
530
534
  except Exception: # plugins have full app access; catch everything to prevent save corruption
531
- logging.exception("Error saving state for plugin %s", name)
535
+ logging.warning(
536
+ "Error saving state for plugin %s", name, exc_info=True
537
+ )
532
538
 
533
539
  if plugin_data:
534
540
  json_data["plugins"] = plugin_data
@@ -564,7 +570,9 @@ class StateManager:
564
570
  try:
565
571
  load_hand(p_state)
566
572
  except Exception: # plugins have full app access; catch everything to prevent load corruption
567
- logging.exception("Error loading state for plugin %s", name)
573
+ logging.warning(
574
+ "Error loading state for plugin %s", name, exc_info=True
575
+ )
568
576
  else:
569
577
  # No handler found (plugin disabled or missing)
570
578
  # Preserve data so it's not lost on next save
@@ -671,5 +679,5 @@ class StateManager:
671
679
  "Suppressed non-critical error", exc_info=True
672
680
  )
673
681
  except (RuntimeError, ValueError, TypeError, binascii.Error) as e:
674
- logging.error(f"Could not restore 3D molecular data: {e}")
682
+ logging.warning(f"Could not restore 3D molecular data: {e}")
675
683
  self.host.set_current_molecule(None)
@@ -96,7 +96,7 @@ class BondItem(QGraphicsItem):
96
96
  view.viewport().update() # type: ignore[union-attr]
97
97
 
98
98
  except (AttributeError, RuntimeError, TypeError):
99
- logging.exception("Error in BondItem.set_stereo")
99
+ logging.warning("Error in BondItem.set_stereo", exc_info=True)
100
100
  # Continue without crashing
101
101
  self.stereo = new_stereo
102
102
 
@@ -604,7 +604,7 @@ class BondItem(QGraphicsItem):
604
604
  self.setPos(self.atom1.pos())
605
605
  self.update()
606
606
  except (AttributeError, RuntimeError):
607
- logging.exception("Error updating bond position")
607
+ logging.warning("Error updating bond position", exc_info=True)
608
608
  # Continue without crashing
609
609
 
610
610
  def hoverEnterEvent(self, event: QGraphicsSceneHoverEvent) -> None: # type: ignore[override]
@@ -390,7 +390,9 @@ class ComputeManager:
390
390
  try:
391
391
  success = bool(entry["callback"](mol))
392
392
  except Exception: # plugins have full app access; isolate failures
393
- logging.exception("Plugin optimization method '%s' failed", label)
393
+ logging.warning(
394
+ "Plugin optimization method '%s' failed", label, exc_info=True
395
+ )
394
396
  self._refresh_ui_state()
395
397
  self.host.update_status_message(
396
398
  f"Plugin optimization '{label}' failed (see log)."
@@ -431,7 +433,7 @@ class ComputeManager:
431
433
  try:
432
434
  Chem.SanitizeMol(mol)
433
435
  except (ValueError, RuntimeError) as e:
434
- logging.error(f"Sanitization failed: {e}")
436
+ logging.warning(f"Sanitization failed: {e}")
435
437
  self.host.statusBar().showMessage("Error: Invalid chemical structure.") # type: ignore[union-attr]
436
438
  return None
437
439
  return mol
@@ -587,7 +589,8 @@ class ComputeManager:
587
589
  msg = str(message)
588
590
 
589
591
  self._remove_calculating_text()
590
- self._restore_button_ui()
592
+ # Full restore like the success path, else Export stays disabled on error.
593
+ self._refresh_ui_state()
591
594
 
592
595
  if msg == "Halt" or msg == "Halted":
593
596
  self.host.statusBar().showMessage("Halted") # type: ignore[union-attr]
@@ -156,7 +156,7 @@ class ConstrainedOptimizationDialog(Dialog3DPickingMixin, QDialog):
156
156
  self.ff_combo.setCurrentText("MMFF94s")
157
157
 
158
158
  except (AttributeError, RuntimeError, ValueError, TypeError):
159
- logging.exception("Could not set default force field")
159
+ logging.warning("Could not set default force field", exc_info=True)
160
160
 
161
161
  def init_ui(self) -> None:
162
162
  """Build the constrained optimization dialog with constraint table and controls."""
@@ -528,9 +528,7 @@ class ConstrainedOptimizationDialog(Dialog3DPickingMixin, QDialog):
528
528
  add_torsion_constraint = ff.UFFAddTorsionConstraint
529
529
 
530
530
  except (AttributeError, RuntimeError, ValueError, TypeError) as e:
531
- QMessageBox.critical(
532
- self, "Error", f"Failed to initialize force field {ff_name}: {e}"
533
- )
531
+ logging.exception("Failed to initialize force field %s: %s", ff_name, e)
534
532
  return
535
533
 
536
534
  # Add constraints
@@ -578,8 +576,7 @@ class ConstrainedOptimizationDialog(Dialog3DPickingMixin, QDialog):
578
576
  )
579
577
 
580
578
  except (AttributeError, RuntimeError, ValueError, TypeError) as e:
581
- QMessageBox.critical(self, "Error", f"Failed to add constraints: {e}")
582
- logging.error("Failed to add constraints", exc_info=True)
579
+ logging.exception("Failed to add constraints: %s", e)
583
580
  return
584
581
 
585
582
  # Execute optimization
@@ -598,7 +595,7 @@ class ConstrainedOptimizationDialog(Dialog3DPickingMixin, QDialog):
598
595
  self._opt_thread.start()
599
596
 
600
597
  except Exception as e:
601
- QMessageBox.critical(self, "Error", f"Failed to start optimization: {e}")
598
+ logging.exception("Failed to start optimization: %s", e)
602
599
  self.optimize_button.setEnabled(True)
603
600
 
604
601
  def _on_optimization_error(self, err_msg: str) -> None:
@@ -673,7 +670,7 @@ class ConstrainedOptimizationDialog(Dialog3DPickingMixin, QDialog):
673
670
  logging.warning("Failed to save constraints post-optimization: %s", e)
674
671
 
675
672
  except (AttributeError, RuntimeError, ValueError, TypeError) as e:
676
- QMessageBox.critical(self, "Error", f"Optimization failed: {e}")
673
+ logging.exception("Optimization failed: %s", e)
677
674
 
678
675
  def closeEvent(self, event: Any) -> None:
679
676
  """Delegate window close to reject."""
@@ -120,7 +120,7 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
120
120
  move_group_dialog = widget
121
121
  break
122
122
  except (AttributeError, RuntimeError, TypeError):
123
- logging.error("Caught exception in " + __file__, exc_info=True)
123
+ logging.warning("Caught exception in " + __file__, exc_info=True)
124
124
 
125
125
  if move_group_dialog and move_group_dialog.group_atoms:
126
126
  # Group drag if selected
@@ -401,7 +401,7 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
401
401
  move_group_dialog = widget
402
402
  break
403
403
  except (AttributeError, RuntimeError, TypeError):
404
- logging.error("Caught exception in " + __file__, exc_info=True)
404
+ logging.warning("Caught exception in " + __file__, exc_info=True)
405
405
  if move_group_dialog and getattr(
406
406
  move_group_dialog, "is_dragging_group_vtk", False
407
407
  ):
@@ -497,7 +497,7 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
497
497
  move_group_dialog = widget
498
498
  break
499
499
  except (AttributeError, RuntimeError, TypeError):
500
- logging.error("Caught exception in " + __file__, exc_info=True)
500
+ logging.warning("Caught exception in " + __file__, exc_info=True)
501
501
  # Prevent multi-click issues
502
502
  if move_group_dialog:
503
503
  if getattr(
@@ -509,7 +509,7 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
509
509
  try:
510
510
  move_group_dialog.on_atom_picked(clicked_atom)
511
511
  except (AttributeError, RuntimeError, TypeError, ValueError) as e:
512
- logging.error(f"Error in toggle: {e}")
512
+ logging.warning(f"Error in toggle: {e}")
513
513
  # Reset if multi-clicked without drag
514
514
  move_group_dialog.is_dragging_group_vtk = False
515
515
  move_group_dialog.drag_start_pos_vtk = None
@@ -589,7 +589,7 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
589
589
 
590
590
  QTimer.singleShot(0, _deferred_group_redraw)
591
591
  except (AttributeError, RuntimeError, TypeError, ValueError):
592
- logging.exception("Error finalizing group drag")
592
+ logging.warning("Error finalizing group drag", exc_info=True)
593
593
  else:
594
594
  # No drag = click only -> toggle
595
595
  clicked_atom = getattr(move_group_dialog, "drag_atom_idx_vtk", None)
@@ -597,7 +597,7 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
597
597
  try:
598
598
  move_group_dialog.on_atom_picked(clicked_atom)
599
599
  except (AttributeError, RuntimeError, TypeError, ValueError):
600
- logging.exception("Error in toggle")
600
+ logging.warning("Error in toggle", exc_info=True)
601
601
 
602
602
  # Background click: deselect Move Group
603
603
  if move_group_dialog and not getattr(
@@ -725,7 +725,9 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
725
725
  ),
726
726
  )
727
727
  except (AttributeError, RuntimeError, ValueError, TypeError):
728
- logging.error("Caught exception in " + __file__, exc_info=True)
728
+ logging.warning(
729
+ "Caught exception in " + __file__, exc_info=True
730
+ )
729
731
 
730
732
  # Defer the redraw + undo push out of the VTK observer
731
733
  # callback to prevent re-entrant plotter.render() deadlock.
@@ -735,7 +737,7 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
735
737
  try:
736
738
  mw.view_3d_manager.draw_molecule_3d(_atom_mol)
737
739
  except (AttributeError, RuntimeError, ValueError, TypeError):
738
- logging.error(
740
+ logging.warning(
739
741
  "Caught exception in " + __file__, exc_info=True
740
742
  )
741
743
  mw.edit_actions_manager.push_undo_state()
@@ -760,7 +762,7 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
760
762
  try:
761
763
  fn()
762
764
  except (AttributeError, RuntimeError, ValueError, TypeError):
763
- logging.error(
765
+ logging.warning(
764
766
  "Caught exception in " + __file__, exc_info=True
765
767
  )
766
768
 
@@ -790,13 +792,13 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
790
792
  if hasattr(move_group_dialog, "drag_atom_idx_vtk"): # Safe
791
793
  delattr(move_group_dialog, "drag_atom_idx_vtk")
792
794
  except (AttributeError, RuntimeError, ValueError, TypeError):
793
- logging.error("Caught exception in " + __file__, exc_info=True)
795
+ logging.warning("Caught exception in " + __file__, exc_info=True)
794
796
 
795
797
  # Update cursor after release
796
798
  try:
797
799
  mw.view_3d_manager.plotter.setCursor(Qt.CursorShape.ArrowCursor)
798
800
  except (AttributeError, RuntimeError, ValueError, TypeError):
799
- logging.error("Caught exception in " + __file__, exc_info=True)
801
+ logging.warning("Caught exception in " + __file__, exc_info=True)
800
802
 
801
803
  # Restore focus to 2D view
802
804
  if mw and mw.init_manager.view_2d:
@@ -820,7 +822,7 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
820
822
  move_group_dialog = widget
821
823
  break
822
824
  except (AttributeError, RuntimeError, TypeError):
823
- logging.error("Caught exception in " + __file__, exc_info=True)
825
+ logging.warning("Caught exception in " + __file__, exc_info=True)
824
826
  if move_group_dialog and getattr(
825
827
  move_group_dialog, "is_rotating_group_vtk", False
826
828
  ):
@@ -935,7 +937,7 @@ class CustomInteractorStyle(vtkInteractorStyleTrackballCamera):
935
937
  move_group_dialog.show_atom_labels()
936
938
  mw.edit_actions_manager.push_undo_state()
937
939
  except (AttributeError, RuntimeError, TypeError, ValueError):
938
- logging.exception("Error finalizing group rotation")
940
+ logging.warning("Error finalizing group rotation", exc_info=True)
939
941
 
940
942
  # Reset state
941
943
  move_group_dialog.is_rotating_group_vtk = False
@@ -100,7 +100,7 @@ class Dialog3DPickingMixin:
100
100
  return False
101
101
 
102
102
  except (AttributeError, RuntimeError, ValueError, TypeError):
103
- logging.exception("Error in eventFilter")
103
+ logging.warning("Error in eventFilter", exc_info=True)
104
104
  return False
105
105
 
106
106
  # Add movement tracking for smart selection
@@ -14,6 +14,7 @@ from __future__ import annotations
14
14
 
15
15
 
16
16
  import json
17
+ import logging
17
18
  import os
18
19
  import sys
19
20
  from typing import Any, List, Literal, Optional, cast
@@ -187,9 +188,7 @@ class DialogManager:
187
188
  )
188
189
 
189
190
  except (AttributeError, RuntimeError, ValueError) as e:
190
- QMessageBox.critical(
191
- self.host, "Error", f"Failed to save template: {str(e)}"
192
- )
191
+ logging.exception("Failed to save template: %s", e)
193
192
 
194
193
  def _show_modeless_dialog(self, dialog: QDialog) -> None:
195
194
  """Show a modeless dialog on top, especially important for macOS."""
@@ -986,7 +986,7 @@ class EditActionsManager:
986
986
  ):
987
987
  problem_map[atom_id] = True
988
988
  except (AttributeError, RuntimeError, ValueError, TypeError) as e:
989
- logging.error(f"Error during chemistry problem detection: {e}")
989
+ logging.warning(f"Error during chemistry problem detection: {e}")
990
990
 
991
991
  return problem_map
992
992
 
@@ -260,7 +260,7 @@ class MainWindow(QMainWindow):
260
260
  self.state_manager.get_current_state()
261
261
  )
262
262
  except (RuntimeError, TypeError, ValueError, AttributeError) as e:
263
- logging.error(f"save_state_snapshot failed: {e}")
263
+ logging.warning(f"save_state_snapshot failed: {e}")
264
264
 
265
265
  def update_window_title(self) -> None:
266
266
  """Update main window title to reflect file path and save status."""