MoleditPy-linux 4.1.3__py3-none-any.whl → 4.2.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.
Files changed (60) hide show
  1. moleditpy_linux/core/mol_geometry.py +61 -4
  2. moleditpy_linux/core/molecular_data.py +35 -13
  3. moleditpy_linux/main.py +7 -4
  4. moleditpy_linux/plugins/plugin_interface.py +2 -2
  5. moleditpy_linux/plugins/plugin_manager.py +29 -18
  6. moleditpy_linux/plugins/plugin_manager_window.py +6 -7
  7. moleditpy_linux/ui/about_dialog.py +1 -1
  8. moleditpy_linux/ui/align_plane_dialog.py +3 -3
  9. moleditpy_linux/ui/alignment_dialog.py +3 -3
  10. moleditpy_linux/ui/analysis_window.py +2 -2
  11. moleditpy_linux/ui/angle_dialog.py +16 -15
  12. moleditpy_linux/ui/app_state.py +19 -9
  13. moleditpy_linux/ui/atom_item.py +9 -6
  14. moleditpy_linux/ui/atom_picking.py +3 -3
  15. moleditpy_linux/ui/base_picking_dialog.py +5 -4
  16. moleditpy_linux/ui/bond_item.py +22 -17
  17. moleditpy_linux/ui/bond_length_dialog.py +15 -14
  18. moleditpy_linux/ui/calculation_worker.py +29 -34
  19. moleditpy_linux/ui/color_settings_dialog.py +11 -11
  20. moleditpy_linux/ui/compute_logic.py +97 -58
  21. moleditpy_linux/ui/constrained_optimization_dialog.py +1 -1
  22. moleditpy_linux/ui/custom_interactor_style.py +27 -16
  23. moleditpy_linux/ui/dialog_3d_picking_mixin.py +9 -5
  24. moleditpy_linux/ui/dihedral_dialog.py +14 -13
  25. moleditpy_linux/ui/edit_3d_logic.py +11 -10
  26. moleditpy_linux/ui/edit_actions_logic.py +62 -59
  27. moleditpy_linux/ui/export_logic.py +13 -7
  28. moleditpy_linux/ui/geometry_base_dialog.py +2 -1
  29. moleditpy_linux/ui/io_logic.py +65 -38
  30. moleditpy_linux/ui/main_window.py +5 -5
  31. moleditpy_linux/ui/main_window_init.py +52 -61
  32. moleditpy_linux/ui/mirror_dialog.py +1 -1
  33. moleditpy_linux/ui/molecular_scene_handler.py +62 -11
  34. moleditpy_linux/ui/molecule_scene.py +12 -14
  35. moleditpy_linux/ui/move_group_dialog.py +14 -14
  36. moleditpy_linux/ui/move_selected_atoms_dialog.py +14 -15
  37. moleditpy_linux/ui/periodic_table_dialog.py +1 -1
  38. moleditpy_linux/ui/planarize_dialog.py +7 -5
  39. moleditpy_linux/ui/plugin_menu_manager.py +7 -7
  40. moleditpy_linux/ui/settings_dialog.py +8 -8
  41. moleditpy_linux/ui/settings_tabs/settings_2d_tab.py +9 -9
  42. moleditpy_linux/ui/settings_tabs/settings_3d_tabs.py +7 -7
  43. moleditpy_linux/ui/settings_tabs/settings_other_tab.py +8 -8
  44. moleditpy_linux/ui/settings_tabs/settings_tab_base.py +15 -14
  45. moleditpy_linux/ui/translation_dialog.py +11 -11
  46. moleditpy_linux/ui/ui_manager.py +6 -6
  47. moleditpy_linux/ui/user_template_dialog.py +5 -5
  48. moleditpy_linux/ui/view_3d_logic.py +69 -65
  49. moleditpy_linux/ui/zoomable_view.py +14 -13
  50. moleditpy_linux/utils/constants.py +11 -4
  51. moleditpy_linux/utils/sip_isdeleted_safe.py +1 -1
  52. moleditpy_linux/utils/suppress_log.py +31 -0
  53. moleditpy_linux/utils/system_utils.py +10 -4
  54. {moleditpy_linux-4.1.3.dist-info → moleditpy_linux-4.2.0.dist-info}/METADATA +6 -6
  55. moleditpy_linux-4.2.0.dist-info/RECORD +76 -0
  56. moleditpy_linux-4.1.3.dist-info/RECORD +0 -75
  57. {moleditpy_linux-4.1.3.dist-info → moleditpy_linux-4.2.0.dist-info}/WHEEL +0 -0
  58. {moleditpy_linux-4.1.3.dist-info → moleditpy_linux-4.2.0.dist-info}/entry_points.txt +0 -0
  59. {moleditpy_linux-4.1.3.dist-info → moleditpy_linux-4.2.0.dist-info}/licenses/LICENSE +0 -0
  60. {moleditpy_linux-4.1.3.dist-info → moleditpy_linux-4.2.0.dist-info}/top_level.txt +0 -0
@@ -517,6 +517,14 @@ def identify_valence_problems(
517
517
  list
518
518
  IDs of atoms with problematic valence.
519
519
  """
520
+ # Preferred: RDKit's own detection on an unsanitized mol. Unlike the
521
+ # heuristic table below, it knows every element and accounts for formal
522
+ # charges and radical electrons (e.g. hypervalent N+, S, B, radicals).
523
+ rdkit_ids = _identify_problems_rdkit(atoms_data, bonds_data)
524
+ if rdkit_ids is not None:
525
+ return rdkit_ids
526
+
527
+ # Fallback heuristic when RDKit cannot even construct the atoms
520
528
  problem_atom_ids = []
521
529
 
522
530
  # Pre-calculate bond orders per atom
@@ -537,6 +545,52 @@ def identify_valence_problems(
537
545
  return problem_atom_ids
538
546
 
539
547
 
548
+ def _identify_problems_rdkit(
549
+ atoms_data: Dict[int, Any], bonds_data: Dict[Tuple[int, int], Any]
550
+ ) -> Optional[List[int]]:
551
+ """Find problem atoms via Chem.DetectChemistryProblems (no sanitization).
552
+
553
+ Returns None when the molecule cannot be constructed at all (unknown
554
+ symbols etc.), signalling the caller to use the heuristic fallback.
555
+ """
556
+ try:
557
+ from rdkit import Chem as _Chem
558
+
559
+ mol = _Chem.RWMol()
560
+ id_to_idx = {}
561
+ for atom_id, data in atoms_data.items():
562
+ atom = _Chem.Atom(data["symbol"])
563
+ atom.SetFormalCharge(data.get("charge", 0))
564
+ atom.SetNumRadicalElectrons(data.get("radical", 0))
565
+ atom.SetNoImplicit(False)
566
+ id_to_idx[atom_id] = mol.AddAtom(atom)
567
+
568
+ bond_type_map = {
569
+ 1.0: _Chem.BondType.SINGLE,
570
+ 1.5: _Chem.BondType.AROMATIC,
571
+ 2.0: _Chem.BondType.DOUBLE,
572
+ 3.0: _Chem.BondType.TRIPLE,
573
+ }
574
+ for (id1, id2), bond in bonds_data.items():
575
+ if id1 not in id_to_idx or id2 not in id_to_idx:
576
+ continue
577
+ order = bond_type_map.get(
578
+ float(bond.get("order", 1)), _Chem.BondType.SINGLE
579
+ )
580
+ mol.AddBond(id_to_idx[id1], id_to_idx[id2], order)
581
+
582
+ idx_to_id = {v: k for k, v in id_to_idx.items()}
583
+ problem_ids = set()
584
+ for prob in _Chem.DetectChemistryProblems(mol.GetMol()):
585
+ if hasattr(prob, "GetAtomIdx"):
586
+ problem_ids.add(idx_to_id[prob.GetAtomIdx()])
587
+ elif hasattr(prob, "GetAtomIndices"):
588
+ problem_ids.update(idx_to_id[i] for i in prob.GetAtomIndices())
589
+ return sorted(problem_ids)
590
+ except (RuntimeError, ValueError, TypeError, KeyError):
591
+ return None
592
+
593
+
540
594
  def optimize_2d_coords(mol: Chem.Mol) -> Dict[int, Tuple[float, float]]:
541
595
  """Generate 2D coordinates using RDKit and return a map of (x, y) tuples."""
542
596
  from rdkit.Chem import AllChem
@@ -618,10 +672,13 @@ def resolve_2d_overlaps(
618
672
  parent = {aid: aid for aid in atom_ids}
619
673
 
620
674
  def find_set(aid: int) -> int:
621
- if parent[aid] == aid:
622
- return aid
623
- parent[aid] = find_set(parent[aid])
624
- return parent[aid]
675
+ # Iterative path compression avoids recursion limits
676
+ root = aid
677
+ while parent[root] != root:
678
+ root = parent[root]
679
+ while parent[aid] != root:
680
+ parent[aid], aid = root, parent[aid]
681
+ return root
625
682
 
626
683
  def unite_sets(aid1: int, aid2: int) -> None:
627
684
  root1 = find_set(aid1)
@@ -101,9 +101,14 @@ class MolecularData:
101
101
  if (id1, id2) in self.bonds:
102
102
  self.bonds[(id1, id2)].update(bond_data)
103
103
  return (id1, id2), "updated"
104
- else:
105
- self.bonds[(id1, id2)] = bond_data
106
- return (id1, id2), "created"
104
+ if (id2, id1) in self.bonds:
105
+ # Re-key reversed entry (stereo direction changed) to avoid duplicates
106
+ existing = self.bonds.pop((id2, id1))
107
+ existing.update(bond_data)
108
+ self.bonds[(id1, id2)] = existing
109
+ return (id1, id2), "updated"
110
+ self.bonds[(id1, id2)] = bond_data
111
+ return (id1, id2), "created"
107
112
 
108
113
  def remove_atom(self, atom_id: int) -> None:
109
114
  """Remove an atom and all bonds involving it from the data model."""
@@ -171,7 +176,9 @@ class MolecularData:
171
176
  atom_id_to_idx_map[atom_id] = idx
172
177
 
173
178
  # save bonds & stereo info (label info is kept here)
174
- bond_stereo_info = {} # bond_idx -> {'type': int, 'atom_ids': (id1,id2), 'bond_data': bond_data}
179
+ bond_stereo_info: Dict[
180
+ int, Dict[str, Any]
181
+ ] = {} # bond_idx -> {'type', 'atom_ids', 'bond_data'}
175
182
  for (id1, id2), bond_data in self.bonds.items():
176
183
  if id1 not in atom_id_to_idx_map or id2 not in atom_id_to_idx_map:
177
184
  continue
@@ -279,8 +286,8 @@ class MolecularData:
279
286
  begin_atom_idx = bond.GetBeginAtomIdx()
280
287
  end_atom_idx = bond.GetEndAtomIdx()
281
288
 
282
- bond_data: dict[str, Any] = info.get("bond_data") or {} # type: ignore[assignment]
283
- stereo_atoms_specified = bond_data.get("stereo_atoms")
289
+ label_bond_data = info.get("bond_data") or {}
290
+ stereo_atoms_specified = label_bond_data.get("stereo_atoms")
284
291
 
285
292
  if stereo_atoms_specified:
286
293
  try:
@@ -338,10 +345,10 @@ class MolecularData:
338
345
 
339
346
  if not self.atoms:
340
347
  return None
341
- atom_map = {old_id: new_id for new_id, old_id in enumerate(self.atoms.keys())}
342
- num_atoms, num_bonds = len(self.atoms), len(self.bonds)
343
- mol_block = "\n MoleditPy\n\n"
344
- mol_block += f"{num_atoms:3d}{num_bonds:3d} 0 0 0 0 0 0 0 0999 V2000\n"
348
+
349
+ # Counts line and bond indices must only reflect atoms actually written
350
+ atom_map: Dict[int, int] = {}
351
+ atom_lines: List[str] = []
345
352
  for old_id, atom in self.atoms.items():
346
353
  # Convert scene pixel coordinates to angstroms when emitting MOL block
347
354
  pos = atom.get("pos")
@@ -373,10 +380,19 @@ class MolecularData:
373
380
  elif charge == -3:
374
381
  chg_code = 7
375
382
 
376
- mol_block += f"{x:10.4f}{y:10.4f}{z:10.4f} {symbol:<3} 0 0 0{chg_code:3d} 0 0 0 0 0 0 0\n"
383
+ atom_map[old_id] = len(atom_lines)
384
+ atom_lines.append(
385
+ f"{x:10.4f}{y:10.4f}{z:10.4f} {symbol:<3} 0 0 0{chg_code:3d} 0 0 0 0 0 0 0\n"
386
+ )
377
387
 
388
+ bond_lines = []
378
389
  for (id1, id2), bond in self.bonds.items():
379
- idx1, idx2, order = atom_map[id1] + 1, atom_map[id2] + 1, bond["order"]
390
+ if id1 not in atom_map or id2 not in atom_map:
391
+ continue
392
+ idx1, idx2 = atom_map[id1] + 1, atom_map[id2] + 1
393
+ # Bond order may be a float (1.5 = aromatic); V2000 uses code 4.
394
+ order_val = float(bond["order"])
395
+ order = 4 if order_val == 1.5 else int(order_val)
380
396
  stereo_code = 0
381
397
  bond_stereo = bond.get("stereo", 0)
382
398
  if bond_stereo == 1:
@@ -384,8 +400,14 @@ class MolecularData:
384
400
  elif bond_stereo == 2:
385
401
  stereo_code = 6
386
402
 
387
- mol_block += f"{idx1:3d}{idx2:3d}{order:3d}{stereo_code:3d} 0 0 0\n"
403
+ bond_lines.append(
404
+ f"{idx1:3d}{idx2:3d}{order:3d}{stereo_code:3d} 0 0 0\n"
405
+ )
388
406
 
407
+ mol_block = "\n MoleditPy\n\n"
408
+ mol_block += f"{len(atom_lines):3d}{len(bond_lines):3d} 0 0 0 0 0 0 0 0999 V2000\n"
409
+ mol_block += "".join(atom_lines)
410
+ mol_block += "".join(bond_lines)
389
411
  mol_block += "M END\n"
390
412
  return mol_block
391
413
 
moleditpy_linux/main.py CHANGED
@@ -17,7 +17,7 @@ import logging.handlers
17
17
  import os
18
18
  import sys
19
19
  import argparse
20
- from typing import Any
20
+ from typing import Any, Optional
21
21
 
22
22
  from .utils.constants import VERSION
23
23
 
@@ -39,8 +39,9 @@ _QT_LOG_LEVEL = {
39
39
  _DOWNGRADED_QT_PATTERNS = ("Retrying to obtain clipboard",)
40
40
 
41
41
 
42
- def _qt_message_handler(mode: QtMsgType, _context: Any, message: str) -> None:
42
+ def _qt_message_handler(mode: QtMsgType, _context: Any, message: Optional[str]) -> None:
43
43
  """Route Qt log messages to Python logging, downgrading known noisy warnings."""
44
+ message = message or ""
44
45
  for pattern in _DOWNGRADED_QT_PATTERNS:
45
46
  if pattern in message:
46
47
  logging.debug("Qt: %s", message)
@@ -109,7 +110,9 @@ def main() -> None:
109
110
  setup_logging()
110
111
 
111
112
  if sys.platform == "win32":
112
- myappid = "hyoko.moleditpy_linux.1.0"
113
+ # Taskbar grouping ID follows the major version
114
+ major = VERSION.split(".")[0] if VERSION and VERSION != "Unknown" else "0"
115
+ myappid = f"hyoko.moleditpy_linux.{major}"
113
116
  ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid)
114
117
 
115
118
  parser = argparse.ArgumentParser(
@@ -201,6 +204,6 @@ def main() -> None:
201
204
  except (
202
205
  Exception
203
206
  ): # [COSMETIC] Icon refresh is best-effort; Qt timing errors are non-fatal.
204
- pass
207
+ logging.debug("Suppressed non-critical error", exc_info=True)
205
208
 
206
209
  sys.exit(app.exec())
@@ -133,7 +133,7 @@ class PluginContext:
133
133
 
134
134
  def get_selected_atom_indices(self) -> List[int]:
135
135
  """
136
- Returns a list of RDKit atom indices currently selected in the 2D or 3D view.
136
+ Returns a list of RDKit atom indices currently selected in the 2D view.
137
137
  Note: RDKit indices are returned, which map to the current_mol.
138
138
  """
139
139
  return self._manager.get_selected_atom_indices() # type: ignore[no-any-return]
@@ -397,7 +397,7 @@ class PluginContext:
397
397
  if fn:
398
398
  fn()
399
399
  except Exception:
400
- pass
400
+ logging.debug("mark_project_modified failed", exc_info=True)
401
401
 
402
402
  def refresh_ui(self) -> None:
403
403
  """Refresh all UI state after modifying the molecule.
@@ -151,21 +151,24 @@ class PluginManager:
151
151
  # Smart Extraction: Check if ZIP has a single top-level folder
152
152
  # Fix for paths with backslashes on Windows if zip was created on Windows
153
153
  roots = set()
154
+ root_is_dir = False
154
155
  for name in zf.namelist():
155
156
  # Normalize path separators to forward slash for consistent check
156
157
  name = name.replace("\\", "/")
157
158
  parts = name.split("/")
158
159
  if parts[0]:
159
160
  roots.add(parts[0])
161
+ # Sub-entries prove the root is a folder
162
+ if len(parts) > 1:
163
+ root_is_dir = True
160
164
 
161
- is_nested = len(roots) == 1
165
+ is_nested = len(roots) == 1 and root_is_dir
162
166
 
163
167
  if is_nested:
164
168
  # Case A: ZIP contains a single folder (e.g. MyPlugin/init.py)
165
169
  top_folder = list(roots)[0]
166
170
 
167
- # Guard: If the single item is __init__.py, we MUST create a wrapper folder
168
- # otherwise we pollute the plugin_dir root.
171
+ # A root named __init__.py always needs a wrapper folder
169
172
  if top_folder == "__init__.py":
170
173
  is_nested = False
171
174
 
@@ -206,9 +209,10 @@ class PluginManager:
206
209
  shutil.copy2(file_path, dest_path)
207
210
  msg = f"Installed {filename}"
208
211
 
209
- # Reload plugins after install
212
+ # Reload plugins after install and rebuild UI immediately
210
213
  if self.main_window:
211
214
  self.discover_plugins(self.main_window)
215
+ self.rebuild_plugin_menus()
212
216
  return True, msg
213
217
  except (
214
218
  AttributeError,
@@ -248,8 +252,10 @@ class PluginManager:
248
252
  return []
249
253
 
250
254
  for root, dirs, files in os.walk(self.plugin_dir):
251
- # Exclude hidden directories
252
- dirs[:] = [d for d in dirs if not d.startswith("__") and d != "__pycache__"]
255
+ # Exclude hidden and dunder directories (e.g. .git, __pycache__)
256
+ dirs[:] = [
257
+ d for d in dirs if not d.startswith("__") and not d.startswith(".")
258
+ ]
253
259
 
254
260
  # [Check] Is current dir a package (plugin body)?
255
261
  if "__init__.py" in files:
@@ -315,6 +321,13 @@ class PluginManager:
315
321
  stub.__package__ = parent_name
316
322
  sys.modules[parent_name] = stub
317
323
 
324
+ # Purge stale cache so package submodules reload from disk on re-exec
325
+ for _cached_key in list(sys.modules.keys()):
326
+ if _cached_key == unique_module_name or _cached_key.startswith(
327
+ unique_module_name + "."
328
+ ):
329
+ del sys.modules[_cached_key]
330
+
318
331
  spec = importlib.util.spec_from_file_location(
319
332
  unique_module_name,
320
333
  filepath,
@@ -325,7 +338,7 @@ class PluginManager:
325
338
  sys.modules[spec.name] = module
326
339
 
327
340
  # Inject category info
328
- module.PLUGIN_CATEGORY = category
341
+ module.PLUGIN_CATEGORY = category # type: ignore[attr-defined]
329
342
 
330
343
  spec.loader.exec_module(module)
331
344
 
@@ -600,10 +613,6 @@ class PluginManager:
600
613
  selected_indices.append(i)
601
614
  except (RuntimeError, ValueError, TypeError):
602
615
  continue
603
- except (
604
- ImportError
605
- ): # [OPTIONAL DEP] importlib.metadata unavailable (<3.8); silently skip.
606
- pass
607
616
  except (RuntimeError, AttributeError) as e:
608
617
  logging.error(f"Error retrieving selected atom indices: {e}")
609
618
 
@@ -652,15 +661,15 @@ class PluginManager:
652
661
  if isinstance(target, ast.Name):
653
662
  # Helper to extract value
654
663
  val = None
655
- if node.value: # AnnAssign might presumably not have value? (though usually does for module globals)
656
- if isinstance(node.value, ast.Constant): # Py3.8+
657
- val = node.value.value
658
- elif isinstance(node.value, ast.Tuple):
664
+ if node.value: # type: ignore[attr-defined] # AnnAssign might presumably not have value? (though usually does for module globals)
665
+ if isinstance(node.value, ast.Constant): # type: ignore[attr-defined] # Py3.8+
666
+ val = node.value.value # type: ignore[attr-defined]
667
+ elif isinstance(node.value, ast.Tuple): # type: ignore[attr-defined]
659
668
  # Handle version tuples e.g. (1, 0, 0)
660
669
  try:
661
670
  # Extract simple constants from tuple
662
671
  elts = []
663
- for elt in node.value.elts:
672
+ for elt in node.value.elts: # type: ignore[attr-defined]
664
673
  if isinstance(elt, ast.Constant):
665
674
  elts.append(elt.value)
666
675
  val = ".".join(map(str, elts))
@@ -670,7 +679,9 @@ class PluginManager:
670
679
  ValueError,
671
680
  TypeError,
672
681
  ): # [AST PARSE] Complex/unexpected AST node shapes during metadata extraction; skip gracefully.
673
- pass
682
+ logging.debug(
683
+ "Suppressed non-critical error", exc_info=True
684
+ )
674
685
 
675
686
  if val is not None:
676
687
  if target.id == "PLUGIN_NAME":
@@ -704,7 +715,7 @@ class PluginManager:
704
715
  elif hasattr(ast, "Str") and isinstance(
705
716
  node.value, getattr(ast, "Str", type(None))
706
717
  ):
707
- val = node.value.s
718
+ val = node.value.s # type: ignore[attr-defined]
708
719
 
709
720
  if val and not info["description"]:
710
721
  info["description"] = val.strip().split("\n")[0]
@@ -37,8 +37,8 @@ class PluginManagerWindow(QDialog):
37
37
 
38
38
  def __init__(self, plugin_manager: Any, parent: Optional[QWidget] = None) -> None:
39
39
  super().__init__(parent)
40
- self.btn_remove = None
41
- self.table = None
40
+ self.btn_remove: Any = None
41
+ self.table: Any = None
42
42
  self.plugin_manager = plugin_manager
43
43
  self.setWindowTitle("Plugin Manager")
44
44
  self.resize(800, 500)
@@ -156,13 +156,12 @@ class PluginManagerWindow(QDialog):
156
156
  self.btn_remove.setEnabled(has_selection)
157
157
 
158
158
  def on_reload(self, silent: bool = False) -> None:
159
- """Reload all plugins from disk and refresh the table."""
160
- # Trigger reload in main manager
159
+ """Reload all plugins from disk, rebuild main-window UI, and refresh the table."""
161
160
  if self.plugin_manager.main_window:
162
161
  self.plugin_manager.discover_plugins(self.plugin_manager.main_window)
162
+ self.plugin_manager.rebuild_plugin_menus()
163
163
  self.refresh_plugin_list()
164
164
 
165
- # For immediate feedback:
166
165
  if not silent:
167
166
  QMessageBox.information(self, "Reloaded", "Plugins have been reloaded.")
168
167
  else:
@@ -242,7 +241,7 @@ class PluginManagerWindow(QDialog):
242
241
  """Accept drag events carrying file URLs."""
243
242
  if event is None:
244
243
  return
245
- if event.mimeData().hasUrls():
244
+ if event.mimeData().hasUrls(): # type: ignore[union-attr]
246
245
  event.accept()
247
246
  else:
248
247
  event.ignore()
@@ -253,7 +252,7 @@ class PluginManagerWindow(QDialog):
253
252
  return
254
253
  files_installed = []
255
254
  errors = []
256
- for url in event.mimeData().urls():
255
+ for url in event.mimeData().urls(): # type: ignore[union-attr]
257
256
  file_path = url.toLocalFile()
258
257
 
259
258
  is_valid = False
@@ -34,7 +34,7 @@ class AboutDialog(QDialog):
34
34
 
35
35
  def __init__(self, main_window: Any, parent: Optional[QWidget] = None) -> None:
36
36
  super().__init__(parent)
37
- self.image_label = None
37
+ self.image_label: Any = None
38
38
  self.main_window = main_window
39
39
  self.setWindowTitle("About MoleditPy")
40
40
  self.setFixedSize(250, 300)
@@ -11,7 +11,7 @@ DOI: 10.5281/zenodo.17268532
11
11
  """
12
12
 
13
13
  import logging
14
- from typing import TYPE_CHECKING, Literal, Optional, Sequence
14
+ from typing import TYPE_CHECKING, Any, Literal, Optional, Sequence
15
15
 
16
16
  import numpy as np
17
17
 
@@ -66,9 +66,9 @@ class AlignPlaneDialog(BasePickingDialog):
66
66
  return self._selected_atoms
67
67
 
68
68
  @selected_atoms.setter
69
- def selected_atoms(self, val: object) -> None:
69
+ def selected_atoms(self, val: Any) -> None:
70
70
  """Replace the selection with a new SelectionList built from val."""
71
- self._selected_atoms = SelectionList(val) # type: ignore[arg-type]
71
+ self._selected_atoms = SelectionList(val)
72
72
 
73
73
  def init_ui(self) -> None:
74
74
  """Build and lay out all widgets for the plane-alignment dialog."""
@@ -11,7 +11,7 @@ DOI: 10.5281/zenodo.17268532
11
11
  """
12
12
 
13
13
  import logging
14
- from typing import TYPE_CHECKING, Literal, Optional, Sequence
14
+ from typing import TYPE_CHECKING, Any, Literal, Optional, Sequence
15
15
 
16
16
  import numpy as np
17
17
 
@@ -74,9 +74,9 @@ class AlignmentDialog(Dialog3DPickingMixin, QDialog):
74
74
  return self._selected_atoms
75
75
 
76
76
  @selected_atoms.setter
77
- def selected_atoms(self, val: object) -> None:
77
+ def selected_atoms(self, val: Any) -> None:
78
78
  """Replace the selection with a new SelectionList built from val."""
79
- self._selected_atoms = SelectionList(val) # type: ignore[arg-type]
79
+ self._selected_atoms = SelectionList(val)
80
80
 
81
81
  def init_ui(self) -> None:
82
82
  """Build and lay out all widgets for the alignment dialog."""
@@ -235,8 +235,8 @@ class AnalysisWindow(QDialog):
235
235
  def copy_to_clipboard(self, text: str) -> None:
236
236
  """Copy text to the system clipboard and show a status bar message."""
237
237
  clipboard = QApplication.clipboard()
238
- clipboard.setText(text)
238
+ clipboard.setText(text) # type: ignore[union-attr]
239
239
  if self.parent() and hasattr(self.parent(), "statusBar"):
240
- self.parent().statusBar().showMessage(
240
+ self.parent().statusBar().showMessage( # type: ignore[union-attr]
241
241
  f"Copied '{text}' to clipboard.", 2000
242
242
  )
@@ -11,8 +11,9 @@ DOI: 10.5281/zenodo.17268532
11
11
  """
12
12
 
13
13
  from __future__ import annotations
14
+ import logging
14
15
 
15
- from typing import TYPE_CHECKING, Optional, Sequence
16
+ from typing import Any, TYPE_CHECKING, Optional, Sequence
16
17
 
17
18
  from PyQt6.QtCore import Qt
18
19
  from PyQt6.QtWidgets import (
@@ -50,18 +51,18 @@ class AngleDialog(GeometryBaseDialog):
50
51
  parent: Optional[QWidget] = None,
51
52
  ) -> None:
52
53
  super().__init__(mol, main_window, parent)
53
- self._baseline_positions = None
54
+ self._baseline_positions: Any = None
54
55
  self._snapshot_positions = None
55
- self.angle_input = None
56
- self.angle_label = None
57
- self.angle_slider = None
58
- self.apply_button = None
59
- self.both_groups_radio = None
60
- self.clear_button = None
56
+ self.angle_input: Any = None
57
+ self.angle_label: Any = None
58
+ self.angle_slider: Any = None
59
+ self.apply_button: Any = None
60
+ self.both_groups_radio: Any = None
61
+ self.clear_button: Any = None
61
62
  self.picker_connection = None
62
- self.rotate_atom_radio = None
63
- self.rotate_group_radio = None
64
- self.selection_label = None
63
+ self.rotate_atom_radio: Any = None
64
+ self.rotate_group_radio: Any = None
65
+ self.selection_label: Any = None
65
66
  self.atom1_idx: Optional[int] = None
66
67
  self.atom2_idx: Optional[int] = None # vertex atom
67
68
  self.atom3_idx: Optional[int] = None
@@ -249,7 +250,7 @@ class AngleDialog(GeometryBaseDialog):
249
250
  self.angle_slider.blockSignals(False)
250
251
  except (AttributeError, RuntimeError, ValueError, TypeError):
251
252
  # Safe defensive fallback catching AttributeError, RuntimeError, ValueError, TypeError
252
- pass
253
+ logging.debug("Suppressed non-critical error", exc_info=True)
253
254
  elif self.atom2_idx is None:
254
255
  symbol1 = self.mol.GetAtomWithIdx(self.atom1_idx).GetSymbol()
255
256
  self.selection_label.setText(
@@ -271,7 +272,7 @@ class AngleDialog(GeometryBaseDialog):
271
272
  self.angle_slider.blockSignals(False)
272
273
  except (AttributeError, RuntimeError, ValueError, TypeError):
273
274
  # Safe defensive fallback catching AttributeError, RuntimeError, ValueError, TypeError
274
- pass
275
+ logging.debug("Suppressed non-critical error", exc_info=True)
275
276
  elif self.atom3_idx is None:
276
277
  symbol1 = self.mol.GetAtomWithIdx(self.atom1_idx).GetSymbol()
277
278
  symbol2 = self.mol.GetAtomWithIdx(self.atom2_idx).GetSymbol()
@@ -295,7 +296,7 @@ class AngleDialog(GeometryBaseDialog):
295
296
  self.angle_slider.blockSignals(False)
296
297
  except (AttributeError, RuntimeError, ValueError, TypeError):
297
298
  # Safe defensive fallback catching AttributeError, RuntimeError, ValueError, TypeError
298
- pass
299
+ logging.debug("Suppressed non-critical error", exc_info=True)
299
300
  else:
300
301
  symbol1 = self.mol.GetAtomWithIdx(self.atom1_idx).GetSymbol()
301
302
  symbol2 = self.mol.GetAtomWithIdx(self.atom2_idx).GetSymbol()
@@ -333,7 +334,7 @@ class AngleDialog(GeometryBaseDialog):
333
334
  self.angle_slider.setEnabled(True)
334
335
  except (AttributeError, RuntimeError, TypeError):
335
336
  # Safe defensive fallback catching AttributeError, RuntimeError, TypeError
336
- pass
337
+ logging.debug("Suppressed non-critical error", exc_info=True)
337
338
 
338
339
  # Add labels
339
340
  self.add_selection_label(self.atom1_idx, "1")
@@ -18,7 +18,7 @@ import binascii
18
18
  import copy
19
19
  import logging
20
20
  import os
21
- import contextlib
21
+ from ..utils.suppress_log import suppress_log
22
22
  from typing import Any, Dict, Optional, Tuple
23
23
 
24
24
 
@@ -203,7 +203,10 @@ class StateManager:
203
203
  )
204
204
  except (RuntimeError, ValueError, TypeError):
205
205
  # Safe defensive fallback catching RuntimeError, ValueError, TypeError
206
- pass
206
+ logging.debug(
207
+ "Suppressed non-critical error",
208
+ exc_info=True,
209
+ )
207
210
 
208
211
  # Sync 2D atoms with 3D actors
209
212
  if self.host.compute_manager:
@@ -237,7 +240,7 @@ class StateManager:
237
240
  else:
238
241
  self.host.clear_3d_view()
239
242
  self.host.init_manager.analysis_action.setEnabled(False)
240
- self.host.init_manager.optimize_3d_button.setEnabled(False)
243
+ self.host.init_manager.optimize_3d_button.setEnabled(False) # type: ignore[union-attr]
241
244
  # Disable 3D features
242
245
  self.host.ui_manager.enable_3d_features(False)
243
246
 
@@ -246,7 +249,7 @@ class StateManager:
246
249
 
247
250
  if loaded_data.get("is_3d_viewer_mode", False):
248
251
  self.host.ui_manager.enter_3d_viewer_mode()
249
- self.host.statusBar().showMessage("Project loaded in 3D Viewer Mode.")
252
+ self.host.statusBar().showMessage("Project loaded in 3D Viewer Mode.") # type: ignore[union-attr]
250
253
  else:
251
254
  self.host.ui_manager.restore_ui_for_editing()
252
255
  # Enable 3D edit features even in 2D editor mode if 3D molecule exists
@@ -489,7 +492,7 @@ class StateManager:
489
492
  except (AttributeError, RuntimeError, TypeError, ValueError):
490
493
  # Suppress InChI generation errors during project save if RDKit lacks InChI support
491
494
  # Safe defensive fallback catching AttributeError, RuntimeError, TypeError, ValueError
492
- pass
495
+ logging.debug("Suppressed non-critical error", exc_info=True)
493
496
 
494
497
  except (AttributeError, RuntimeError, ValueError, TypeError) as e:
495
498
  logging.warning("Could not generate molecular identifiers: %s", e)
@@ -543,7 +546,7 @@ class StateManager:
543
546
  is_3d_mode = json_data.get("is_3d_viewer_mode", False)
544
547
  # Restore last successful optimization method if present in file
545
548
  method = json_data.get("last_successful_optimization_method", None)
546
- with contextlib.suppress(AttributeError, RuntimeError, TypeError):
549
+ with suppress_log(AttributeError, RuntimeError, TypeError):
547
550
  self.host.set_last_successful_optimization_method(method)
548
551
 
549
552
  # Plugin State Restoration (Phase 3)
@@ -627,7 +630,10 @@ class StateManager:
627
630
  TypeError,
628
631
  IndexError,
629
632
  ): # [RDKIT GUARD] RDKit atom property assignment may fail on invalid ids; skip silently.
630
- pass
633
+ logging.debug(
634
+ "Suppressed non-critical error",
635
+ exc_info=True,
636
+ )
631
637
  self.host.set_3d_atom_positions(positions_3d)
632
638
 
633
639
  # Build mapping
@@ -637,7 +643,9 @@ class StateManager:
637
643
  self.host.view_3d_manager.update_atom_id_menu_state()
638
644
  except (RuntimeError, TypeError, AttributeError):
639
645
  # Safe defensive fallback catching RuntimeError, TypeError, AttributeError
640
- pass
646
+ logging.debug(
647
+ "Suppressed non-critical error", exc_info=True
648
+ )
641
649
 
642
650
  # Always show 3D if 3D molecule exists
643
651
  self.host.view_3d_manager.draw_molecule_3d(
@@ -659,7 +667,9 @@ class StateManager:
659
667
  self.host.ui_manager.enable_3d_features(True)
660
668
  except (RuntimeError, TypeError, AttributeError):
661
669
  # Safe defensive fallback catching RuntimeError, TypeError, AttributeError
662
- pass
670
+ logging.debug(
671
+ "Suppressed non-critical error", exc_info=True
672
+ )
663
673
  except (RuntimeError, ValueError, TypeError, binascii.Error) as e:
664
674
  logging.error(f"Could not restore 3D molecular data: {e}")
665
675
  self.host.set_current_molecule(None)