MoleditPy-linux 4.0.1__tar.gz → 4.1.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 (80) hide show
  1. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/PKG-INFO +1 -1
  2. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/pyproject.toml +1 -1
  3. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/MoleditPy_linux.egg-info/PKG-INFO +1 -1
  4. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/core/mol_geometry.py +24 -8
  5. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/core/molecular_data.py +9 -0
  6. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/main.py +65 -12
  7. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/plugins/plugin_interface.py +36 -33
  8. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/plugins/plugin_manager.py +14 -0
  9. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/plugins/plugin_manager_window.py +11 -1
  10. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/ui/about_dialog.py +3 -0
  11. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/ui/analysis_window.py +4 -0
  12. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/ui/angle_dialog.py +3 -0
  13. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/ui/app_state.py +9 -7
  14. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/ui/atom_item.py +57 -19
  15. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/ui/bond_item.py +33 -23
  16. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/ui/bond_length_dialog.py +3 -0
  17. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/ui/calculation_worker.py +22 -26
  18. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/ui/color_settings_dialog.py +5 -0
  19. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/ui/compute_logic.py +6 -3
  20. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/ui/constrained_optimization_dialog.py +13 -4
  21. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/ui/custom_interactor_style.py +2 -0
  22. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/ui/custom_qt_interactor.py +2 -0
  23. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/ui/dihedral_dialog.py +3 -0
  24. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/ui/edit_actions_logic.py +19 -5
  25. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/ui/export_logic.py +4 -1
  26. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/ui/io_logic.py +7 -5
  27. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/ui/main_window.py +2 -0
  28. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/ui/main_window_init.py +5 -0
  29. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/ui/mirror_dialog.py +1 -0
  30. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/ui/molecular_scene_handler.py +6 -0
  31. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/ui/molecule_scene.py +29 -15
  32. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/ui/move_group_dialog.py +4 -0
  33. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/ui/periodic_table_dialog.py +11 -6
  34. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/ui/planarize_dialog.py +5 -0
  35. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/ui/settings_dialog.py +8 -0
  36. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/ui/settings_tabs/settings_2d_tab.py +49 -0
  37. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/ui/settings_tabs/settings_3d_tabs.py +4 -0
  38. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/ui/settings_tabs/settings_other_tab.py +35 -0
  39. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/ui/template_preview_item.py +8 -0
  40. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/ui/ui_manager.py +6 -0
  41. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/ui/user_template_dialog.py +2 -1
  42. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/ui/view_3d_logic.py +0 -2
  43. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/ui/zoomable_view.py +4 -0
  44. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/utils/default_settings.py +5 -0
  45. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/LICENSE +0 -0
  46. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/README.md +0 -0
  47. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/setup.cfg +0 -0
  48. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/MoleditPy_linux.egg-info/SOURCES.txt +0 -0
  49. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/MoleditPy_linux.egg-info/dependency_links.txt +0 -0
  50. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/MoleditPy_linux.egg-info/entry_points.txt +0 -0
  51. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/MoleditPy_linux.egg-info/requires.txt +0 -0
  52. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/MoleditPy_linux.egg-info/top_level.txt +0 -0
  53. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/__init__.py +0 -0
  54. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/__main__.py +0 -0
  55. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/assets/file_icon.ico +0 -0
  56. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/assets/icon.icns +0 -0
  57. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/assets/icon.ico +0 -0
  58. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/assets/icon.png +0 -0
  59. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/core/__init__.py +0 -0
  60. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/plugins/__init__.py +0 -0
  61. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/ui/__init__.py +0 -0
  62. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/ui/align_plane_dialog.py +0 -0
  63. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/ui/alignment_dialog.py +0 -0
  64. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/ui/atom_picking.py +0 -0
  65. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/ui/base_picking_dialog.py +0 -0
  66. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/ui/dialog_3d_picking_mixin.py +0 -0
  67. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/ui/dialog_logic.py +0 -0
  68. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/ui/edit_3d_logic.py +0 -0
  69. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/ui/geometry_base_dialog.py +0 -0
  70. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/ui/move_selected_atoms_dialog.py +0 -0
  71. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/ui/plugin_menu_manager.py +0 -0
  72. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/ui/settings_tabs/__init__.py +0 -0
  73. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/ui/settings_tabs/settings_tab_base.py +0 -0
  74. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/ui/string_importers.py +0 -0
  75. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/ui/template_preview_view.py +0 -0
  76. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/ui/translation_dialog.py +0 -0
  77. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/utils/__init__.py +0 -0
  78. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/utils/constants.py +0 -0
  79. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.0}/src/moleditpy_linux/utils/sip_isdeleted_safe.py +0 -0
  80. {moleditpy_linux-4.0.1 → moleditpy_linux-4.1.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.0.1
3
+ Version: 4.1.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.0.1"
8
+ version = "4.1.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.0.1
3
+ Version: 4.1.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
@@ -13,7 +13,21 @@ DOI: 10.5281/zenodo.17268532
13
13
  from __future__ import annotations
14
14
  import math
15
15
  from collections import deque
16
- from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, Union
16
+ from typing import (
17
+ TYPE_CHECKING,
18
+ Any,
19
+ Callable,
20
+ Dict,
21
+ Iterable,
22
+ List,
23
+ Optional,
24
+ Set,
25
+ Tuple,
26
+ Union,
27
+ )
28
+
29
+ if TYPE_CHECKING:
30
+ from rdkit import Chem
17
31
 
18
32
  import numpy as np
19
33
 
@@ -82,7 +96,7 @@ def calc_angle_deg(
82
96
 
83
97
 
84
98
  def get_connected_group(
85
- mol: Any, start_atom: int, exclude: Optional[int] = None
99
+ mol: Chem.Mol, start_atom: int, exclude: Optional[int] = None
86
100
  ) -> Set[int]:
87
101
  """Return the set of atom indices reachable from *start_atom*
88
102
  without passing through *exclude*.
@@ -422,7 +436,9 @@ def is_problematic_valence(
422
436
 
423
437
 
424
438
  def inject_ez_stereo_to_mol_block(
425
- mol_block: str, rdkit_mol: Any, bonds_data: Dict[Tuple[int, int], Any]
439
+ mol_block: str,
440
+ rdkit_mol: Chem.Mol,
441
+ bonds_data: Dict[Tuple[int, int], Dict[str, Any]],
426
442
  ) -> str:
427
443
  """Generate a modified MOL block with 'M CFG' lines for E/Z stereochemistry.
428
444
 
@@ -504,7 +520,7 @@ def identify_valence_problems(
504
520
  problem_atom_ids = []
505
521
 
506
522
  # Pre-calculate bond orders per atom
507
- bond_orders: Dict[int, int] = {}
523
+ bond_orders: Dict[int, float] = {}
508
524
  for (id1, id2), bond in bonds_data.items():
509
525
  order = bond.get("order", 1)
510
526
  bond_orders[id1] = bond_orders.get(id1, 0) + order
@@ -521,7 +537,7 @@ def identify_valence_problems(
521
537
  return problem_atom_ids
522
538
 
523
539
 
524
- def optimize_2d_coords(mol: Any) -> Dict[int, Tuple[float, float]]:
540
+ def optimize_2d_coords(mol: Chem.Mol) -> Dict[int, Tuple[float, float]]:
525
541
  """Generate 2D coordinates using RDKit and return a map of (x, y) tuples."""
526
542
  from rdkit.Chem import AllChem
527
543
 
@@ -572,7 +588,7 @@ def resolve_2d_overlaps(
572
588
  adjacency_list: Dict[int, List[int]],
573
589
  overlap_threshold: float = 0.5,
574
590
  move_distance: float = 20,
575
- has_bond_check_func: Optional[Any] = None,
591
+ has_bond_check_func: Optional[Callable[[int, int], bool]] = None,
576
592
  ) -> List[Tuple[Set[int], Tuple[float, float]]]:
577
593
  """Detect and resolve overlapping atom groups in 2D.
578
594
 
@@ -601,13 +617,13 @@ def resolve_2d_overlaps(
601
617
  # Union-Find for overlap groups
602
618
  parent = {aid: aid for aid in atom_ids}
603
619
 
604
- def find_set(aid: Any) -> Any:
620
+ def find_set(aid: int) -> int:
605
621
  if parent[aid] == aid:
606
622
  return aid
607
623
  parent[aid] = find_set(parent[aid])
608
624
  return parent[aid]
609
625
 
610
- def unite_sets(aid1: Any, aid2: Any) -> None:
626
+ def unite_sets(aid1: int, aid2: int) -> None:
611
627
  root1 = find_set(aid1)
612
628
  root2 = find_set(aid2)
613
629
  if root1 != root2:
@@ -22,13 +22,17 @@ class PointTuple(tuple):
22
22
  """Backward-compatible tuple that allows .x() and .y() access like QPointF."""
23
23
 
24
24
  def x(self) -> float:
25
+ """Return the x coordinate as a float."""
25
26
  return float(self[0])
26
27
 
27
28
  def y(self) -> float:
29
+ """Return the y coordinate as a float."""
28
30
  return float(self[1])
29
31
 
30
32
 
31
33
  class MolecularData:
34
+ """In-memory graph of atoms and bonds, independent of any UI framework."""
35
+
32
36
  atoms: Dict[int, Dict[str, Any]]
33
37
  bonds: Dict[Tuple[int, int], Dict[str, Any]]
34
38
  adjacency_list: Dict[int, List[int]]
@@ -47,6 +51,7 @@ class MolecularData:
47
51
  charge: int = 0,
48
52
  radical: int = 0,
49
53
  ) -> int:
54
+ """Add a new atom and return its auto-assigned integer ID."""
50
55
  atom_id = self.next_atom_id
51
56
  # Internalize position as raw floats to decouple from UI types (QPointF)
52
57
  if hasattr(pos, "x") and hasattr(pos, "y"):
@@ -77,6 +82,7 @@ class MolecularData:
77
82
  def add_bond(
78
83
  self, id1: int, id2: int, order: Union[int, float] = 1, stereo: int = 0
79
84
  ) -> Tuple[Tuple[int, int], str]:
85
+ """Add or update a bond between two atoms; return its canonical key and 'created'/'updated'."""
80
86
  # For stereo bonds, do not sort because ID order determines direction.
81
87
  # For non-stereo bonds, sort to normalize the key.
82
88
  if stereo == 0:
@@ -100,6 +106,7 @@ class MolecularData:
100
106
  return (id1, id2), "created"
101
107
 
102
108
  def remove_atom(self, atom_id: int) -> None:
109
+ """Remove an atom and all bonds involving it from the data model."""
103
110
  if atom_id in self.atoms:
104
111
  # Safely get neighbors before deleting the atom's own entry
105
112
  neighbors = self.adjacency_list.get(atom_id, [])
@@ -123,6 +130,7 @@ class MolecularData:
123
130
  self.bonds.pop(key, None)
124
131
 
125
132
  def remove_bond(self, id1: int, id2: int) -> None:
133
+ """Remove the bond between two atoms, handling stereo and non-stereo key variants."""
126
134
  # Look for directional stereo bonds (forward/reverse) and normalized non-stereo bond keys.
127
135
  key_to_remove = None
128
136
  if (id1, id2) in self.bonds:
@@ -318,6 +326,7 @@ class MolecularData:
318
326
  return final_mol
319
327
 
320
328
  def to_mol_block(self) -> Optional[str]:
329
+ """Serialize the molecule to an MDL MOL block string, or None on failure."""
321
330
  mol = self.to_rdkit_mol()
322
331
  if mol:
323
332
  try:
@@ -11,36 +11,92 @@ DOI: 10.5281/zenodo.17268532
11
11
  """
12
12
 
13
13
  import ctypes
14
- import sys
15
- import argparse
14
+ import json
16
15
  import logging
16
+ import logging.handlers
17
17
  import os
18
+ import sys
19
+ import argparse
18
20
  from typing import Any
19
21
 
20
22
  from .utils.constants import VERSION
21
23
 
22
24
  # VERSION is resolved above (before Qt) so --version works without launching the app.
23
25
 
26
+ from PyQt6.QtCore import QtMsgType, qInstallMessageHandler
24
27
  from PyQt6.QtWidgets import QApplication
25
28
 
26
29
  from .ui.main_window import MainWindow
27
30
 
31
+ _QT_LOG_LEVEL = {
32
+ QtMsgType.QtDebugMsg: logging.DEBUG,
33
+ QtMsgType.QtInfoMsg: logging.INFO,
34
+ QtMsgType.QtWarningMsg: logging.WARNING,
35
+ QtMsgType.QtCriticalMsg: logging.ERROR,
36
+ QtMsgType.QtFatalMsg: logging.CRITICAL,
37
+ }
38
+
39
+ _DOWNGRADED_QT_PATTERNS = ("Retrying to obtain clipboard",)
40
+
41
+
42
+ def _qt_message_handler(mode: QtMsgType, _context: Any, message: str) -> None:
43
+ """Route Qt log messages to Python logging, downgrading known noisy warnings."""
44
+ for pattern in _DOWNGRADED_QT_PATTERNS:
45
+ if pattern in message:
46
+ logging.debug("Qt: %s", message)
47
+ return
48
+ logging.log(_QT_LOG_LEVEL.get(mode, logging.WARNING), "Qt: %s", message)
49
+
50
+
51
+ def _read_startup_log_settings() -> tuple[bool, bool]:
52
+ """Read log_to_file and log_level_debug from settings.json before Qt starts.
53
+
54
+ Returns (log_to_file, log_level_debug). Falls back to (False, False) on any error.
55
+ """
56
+ settings_path = os.path.join(os.path.expanduser("~"), ".moleditpy", "settings.json")
57
+ try:
58
+ with open(settings_path, encoding="utf-8") as f:
59
+ data = json.load(f)
60
+ return bool(data.get("log_to_file", False)), bool(
61
+ data.get("log_level_debug", False)
62
+ )
63
+ except (OSError, json.JSONDecodeError, ValueError):
64
+ return False, False
65
+
28
66
 
29
67
  def setup_logging() -> None:
68
+ """Configure root logger and install a global unhandled-exception handler."""
69
+ log_to_file, log_level_debug = _read_startup_log_settings()
70
+ level = logging.DEBUG if log_level_debug else logging.INFO
71
+ fmt = "%(asctime)s [%(levelname)s] %(name)s (%(pathname)s:%(lineno)d): %(message)s"
72
+
30
73
  logging.basicConfig(
31
- level=logging.INFO,
32
- format="%(asctime)s [%(levelname)s] %(name)s (%(pathname)s:%(lineno)d): %(message)s",
74
+ level=level,
75
+ format=fmt,
33
76
  stream=sys.stdout,
34
77
  force=True,
35
78
  )
36
79
 
80
+ if log_to_file:
81
+ log_dir = os.path.join(os.path.expanduser("~"), ".moleditpy")
82
+ try:
83
+ os.makedirs(log_dir, exist_ok=True)
84
+ log_path = os.path.join(log_dir, "moleditpy_linux.log")
85
+ fh = logging.handlers.RotatingFileHandler(
86
+ log_path, maxBytes=1_048_576, backupCount=3, encoding="utf-8"
87
+ )
88
+ fh.setLevel(level)
89
+ fh.setFormatter(logging.Formatter(fmt))
90
+ logging.getLogger().addHandler(fh)
91
+ logging.info("File logging enabled: %s", log_path)
92
+ except OSError as e:
93
+ logging.warning("Could not open log file: %s", e)
94
+
37
95
  def handle_exception(exc_type: Any, exc_value: Any, exc_traceback: Any) -> None:
38
96
  """Log unhandled exceptions using the configured logging system."""
39
97
  if issubclass(exc_type, KeyboardInterrupt):
40
- # Allow keyboard interrupt to exit normally
41
98
  sys.__excepthook__(exc_type, exc_value, exc_traceback)
42
99
  return
43
-
44
100
  logging.error(
45
101
  "Uncaught exception", exc_info=(exc_type, exc_value, exc_traceback)
46
102
  )
@@ -49,12 +105,11 @@ def setup_logging() -> None:
49
105
 
50
106
 
51
107
  def main() -> None:
52
- # Setup logging as early as possible
108
+ """Parse CLI arguments, configure logging, and launch the GUI."""
53
109
  setup_logging()
54
110
 
55
- # --- Additional handling for Windows taskbar icon ---
56
111
  if sys.platform == "win32":
57
- myappid = "hyoko.moleditpy_linux.1.0" # Application-specific ID (arbitrary)
112
+ myappid = "hyoko.moleditpy_linux.1.0"
58
113
  ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid)
59
114
 
60
115
  parser = argparse.ArgumentParser(
@@ -80,7 +135,6 @@ def main() -> None:
80
135
  # parse_known_args so Qt's own argv flags (e.g. -platform) are passed through
81
136
  args, remaining = parser.parse_known_args()
82
137
 
83
- # --- Headless Plugin Installation ---
84
138
  if args.install_plugin:
85
139
  plugin_path = os.path.abspath(args.install_plugin)
86
140
  if not os.path.exists(plugin_path):
@@ -95,7 +149,6 @@ def main() -> None:
95
149
  pm = PluginManager()
96
150
  sha256 = pm.compute_sha256(plugin_path)
97
151
 
98
- # Extract metadata
99
152
  metadata_file = plugin_path
100
153
  if os.path.isdir(plugin_path):
101
154
  init_py = os.path.join(plugin_path, "__init__.py")
@@ -136,10 +189,10 @@ def main() -> None:
136
189
  sys.exit(0)
137
190
 
138
191
  app = QApplication([sys.argv[0]] + remaining)
192
+ qInstallMessageHandler(_qt_message_handler)
139
193
  window = MainWindow(initial_file=args.file, safe_mode=args.safe)
140
194
  window.show()
141
195
 
142
- # Force Windows to refresh taskbar/titlebar icon after event loop starts
143
196
  if sys.platform == "win32":
144
197
  try:
145
198
  from PyQt6.QtCore import QTimer
@@ -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
  from typing import Any, Callable, List, Optional, Union
14
15
 
15
16
 
@@ -173,10 +174,11 @@ class PluginContext:
173
174
  """Switch the application UI layout to 3D viewer mode (Public API)."""
174
175
  mw = self.get_main_window()
175
176
  if mw is not None and hasattr(mw, "ui_manager"):
176
- if hasattr(mw.ui_manager, "enter_3d_viewer_mode"):
177
- mw.ui_manager.enter_3d_viewer_mode()
178
- elif hasattr(mw.ui_manager, "enter_3d_viewer_ui_mode"):
179
- mw.ui_manager.enter_3d_viewer_ui_mode()
177
+ fn = getattr(mw.ui_manager, "enter_3d_viewer_mode", None) or getattr(
178
+ mw.ui_manager, "enter_3d_viewer_ui_mode", None
179
+ )
180
+ if fn:
181
+ fn()
180
182
 
181
183
  def enter_3d_mode(self) -> None:
182
184
  """Switch UI layout to 3D viewer mode. Alias for enter_3d_viewer_mode."""
@@ -240,16 +242,11 @@ class PluginContext:
240
242
  """Force the 3D window to redraw using the current molecule."""
241
243
  mw = self.get_main_window()
242
244
  if mw and hasattr(mw, "view_3d_manager"):
243
- mol = getattr(mw.view_3d_manager, "current_mol", None)
245
+ mol = mw.view_3d_manager.current_mol
244
246
  if mol:
245
247
  mw.view_3d_manager.draw_molecule_3d(mol)
246
- else:
247
- # Also redraw/clear plotter if no molecule
248
- if (
249
- hasattr(mw.view_3d_manager, "plotter")
250
- and mw.view_3d_manager.plotter
251
- ):
252
- mw.view_3d_manager.plotter.render()
248
+ elif mw.view_3d_manager.plotter:
249
+ mw.view_3d_manager.plotter.render()
253
250
 
254
251
  def reset_3d_camera(self) -> None:
255
252
  """Zoom in and re-center the 3D viewport to fit the current molecule."""
@@ -370,7 +367,7 @@ class PluginContext:
370
367
  default: Value to return if the setting is not found.
371
368
  """
372
369
  mw = self.get_main_window()
373
- if mw and hasattr(mw, "init_manager") and hasattr(mw.init_manager, "settings"):
370
+ if mw and hasattr(mw, "init_manager"):
374
371
  namespaced = f"plugin.{self._plugin_name}.{key}"
375
372
  return mw.init_manager.settings.get(namespaced, default)
376
373
  return default
@@ -387,11 +384,10 @@ class PluginContext:
387
384
  value: Value to store (must be JSON-serializable).
388
385
  """
389
386
  mw = self.get_main_window()
390
- if mw and hasattr(mw, "init_manager") and hasattr(mw.init_manager, "settings"):
387
+ if mw and hasattr(mw, "init_manager"):
391
388
  namespaced = f"plugin.{self._plugin_name}.{key}"
392
389
  mw.init_manager.settings[namespaced] = value
393
- if hasattr(mw.init_manager, "settings_dirty"):
394
- mw.init_manager.settings_dirty = True
390
+ mw.init_manager.settings_dirty = True
395
391
 
396
392
  def mark_project_modified(self) -> None:
397
393
  """Mark the current project as having unsaved changes and update the window title."""
@@ -399,8 +395,9 @@ class PluginContext:
399
395
  if mw and hasattr(mw, "state_manager"):
400
396
  try:
401
397
  mw.state_manager.has_unsaved_changes = True
402
- if hasattr(mw.state_manager, "update_window_title"):
403
- mw.state_manager.update_window_title()
398
+ fn = getattr(mw.state_manager, "update_window_title", None)
399
+ if fn:
400
+ fn()
404
401
  except Exception:
405
402
  pass
406
403
 
@@ -414,14 +411,16 @@ class PluginContext:
414
411
  if mw is None:
415
412
  return
416
413
  if hasattr(mw, "state_manager"):
417
- if hasattr(mw.state_manager, "update_realtime_info"):
418
- mw.state_manager.update_realtime_info()
419
- if hasattr(mw.state_manager, "update_window_title"):
420
- mw.state_manager.update_window_title()
421
- if hasattr(mw, "edit_actions_manager") and hasattr(
422
- mw.edit_actions_manager, "update_undo_redo_actions"
423
- ):
424
- mw.edit_actions_manager.update_undo_redo_actions()
414
+ fn = getattr(mw.state_manager, "update_realtime_info", None)
415
+ if fn:
416
+ fn()
417
+ fn = getattr(mw.state_manager, "update_window_title", None)
418
+ if fn:
419
+ fn()
420
+ if hasattr(mw, "edit_actions_manager"):
421
+ fn = getattr(mw.edit_actions_manager, "update_undo_redo_actions", None)
422
+ if fn:
423
+ fn()
425
424
 
426
425
  def fit_3d_view(self) -> None:
427
426
  """Zoom and re-center the 3D viewport to fit the current molecule."""
@@ -489,8 +488,10 @@ class PluginContext:
489
488
  mw = self.get_main_window()
490
489
  if mw and hasattr(mw, "init_manager"):
491
490
  scene = getattr(mw.init_manager, "scene", None)
492
- if scene is not None and hasattr(scene, "update_all_items"):
493
- scene.update_all_items()
491
+ if scene is not None:
492
+ fn = getattr(scene, "update_all_items", None)
493
+ if fn:
494
+ fn()
494
495
 
495
496
  def load_from_smiles(self, smiles: str) -> None:
496
497
  """Add a molecule from a SMILES string to the 2D editor."""
@@ -518,6 +519,7 @@ class PluginContext:
518
519
 
519
520
  return "\n".join(xyz_lines)
520
521
  except Exception:
522
+ logging.debug("to_xyz_block failed", exc_info=True)
521
523
  return None
522
524
 
523
525
 
@@ -541,8 +543,8 @@ class Plugin3DController:
541
543
  v3d = self._get_v3d()
542
544
  if v3d:
543
545
  v3d.update_atom_color_override(atom_index, color_hex)
544
- if hasattr(self._mw, "plotter") and self._mw.plotter:
545
- self._mw.plotter.render()
546
+ if v3d.plotter:
547
+ v3d.plotter.render()
546
548
 
547
549
  def set_bond_color(self, bond_index: int, color_hex: str) -> None:
548
550
  """
@@ -555,8 +557,8 @@ class Plugin3DController:
555
557
  v3d = self._get_v3d()
556
558
  if v3d:
557
559
  v3d.update_bond_color_override(bond_index, color_hex)
558
- if hasattr(self._mw, "plotter") and self._mw.plotter:
559
- self._mw.plotter.render()
560
+ if v3d.plotter:
561
+ v3d.plotter.render()
560
562
 
561
563
  def set_bond_color_by_atoms(
562
564
  self, atom_idx1: int, atom_idx2: int, color_hex: str
@@ -569,7 +571,8 @@ class Plugin3DController:
569
571
  atom_idx2: Second RDKit atom index.
570
572
  color_hex: Hex string e.g., "#00FF00".
571
573
  """
572
- mol = getattr(self._mw, "current_mol", None)
574
+ v3d = self._get_v3d()
575
+ mol = v3d.current_mol if v3d else None
573
576
  if not mol:
574
577
  return
575
578
 
@@ -29,6 +29,8 @@ from .plugin_interface import PluginContext
29
29
 
30
30
 
31
31
  class PluginManager:
32
+ """Discovers, loads, and manages lifecycle of all installed plugins."""
33
+
32
34
  def compute_sha256(self, path: str) -> str:
33
35
  """Computes SHA-256 for a file or a directory (concatenated hashes of all files)."""
34
36
  if os.path.isfile(path):
@@ -98,9 +100,11 @@ class PluginManager:
98
100
  ] = {} # Map of plugin_name -> {window_id -> window}
99
101
 
100
102
  def get_main_window(self) -> Any:
103
+ """Return the current main window reference."""
101
104
  return self.main_window
102
105
 
103
106
  def set_main_window(self, mw: Any) -> None:
107
+ """Set the main window reference."""
104
108
  self.main_window = mw
105
109
 
106
110
  def ensure_plugin_dir(self) -> None:
@@ -420,6 +424,7 @@ class PluginManager:
420
424
  icon: str,
421
425
  shortcut: str,
422
426
  ) -> None:
427
+ """Register a plugin menu action with its path, callback, and display metadata."""
423
428
  self.menu_actions.append(
424
429
  {
425
430
  "plugin": plugin_name,
@@ -434,6 +439,7 @@ class PluginManager:
434
439
  def register_toolbar_action(
435
440
  self, plugin_name: str, callback: Callable, text: str, icon: str, tooltip: str
436
441
  ) -> None:
442
+ """Register a plugin toolbar button with its callback and display metadata."""
437
443
  self.toolbar_actions.append(
438
444
  {
439
445
  "plugin": plugin_name,
@@ -447,6 +453,7 @@ class PluginManager:
447
453
  def register_drop_handler(
448
454
  self, plugin_name: str, callback: Callable, priority: int
449
455
  ) -> None:
456
+ """Register a drag-and-drop handler with a given priority."""
450
457
  self.drop_handlers.append(
451
458
  {"priority": priority, "plugin": plugin_name, "callback": callback}
452
459
  )
@@ -456,6 +463,7 @@ class PluginManager:
456
463
  def register_export_action(
457
464
  self, plugin_name: str, label: str, callback: Callable
458
465
  ) -> None:
466
+ """Register a plugin export action with its label and callback."""
459
467
  self.export_actions.append(
460
468
  {"plugin": plugin_name, "label": label, "callback": callback}
461
469
  )
@@ -463,6 +471,7 @@ class PluginManager:
463
471
  def register_optimization_method(
464
472
  self, plugin_name: str, method_name: str, callback: Callable
465
473
  ) -> None:
474
+ """Register a named 3D optimization method provided by a plugin."""
466
475
  # Key by upper-case method name for consistency
467
476
  self.optimization_methods[method_name.upper()] = {
468
477
  "plugin": plugin_name,
@@ -473,6 +482,7 @@ class PluginManager:
473
482
  def register_file_opener(
474
483
  self, plugin_name: str, extension: str, callback: Callable, priority: int = 0
475
484
  ) -> None:
485
+ """Register a file-type handler for the given extension."""
476
486
  # Normalize extension to lowercase
477
487
  ext = extension.lower()
478
488
  if not ext.startswith("."):
@@ -492,20 +502,24 @@ class PluginManager:
492
502
  def register_analysis_tool(
493
503
  self, plugin_name: str, label: str, callback: Callable
494
504
  ) -> None:
505
+ """Register a plugin analysis tool with its label and callback."""
495
506
  self.analysis_tools.append(
496
507
  {"plugin": plugin_name, "label": label, "callback": callback}
497
508
  )
498
509
 
499
510
  # State Persistence registration
500
511
  def register_save_handler(self, plugin_name: str, callback: Callable) -> None:
512
+ """Register a plugin session-save callback."""
501
513
  self.save_handlers[plugin_name] = callback
502
514
 
503
515
  def register_load_handler(self, plugin_name: str, callback: Callable) -> None:
516
+ """Register a plugin session-load callback."""
504
517
  self.load_handlers[plugin_name] = callback
505
518
 
506
519
  def register_3d_style(
507
520
  self, plugin_name: str, style_name: str, callback: Callable
508
521
  ) -> None:
522
+ """Register a named custom 3D rendering style."""
509
523
  self.custom_3d_styles[style_name] = {
510
524
  "plugin": plugin_name,
511
525
  "callback": callback,
@@ -33,6 +33,8 @@ from PyQt6.QtWidgets import (
33
33
 
34
34
 
35
35
  class PluginManagerWindow(QDialog):
36
+ """Dialog for browsing, installing, and removing plugins."""
37
+
36
38
  def __init__(self, plugin_manager: Any, parent: Optional[QWidget] = None) -> None:
37
39
  super().__init__(parent)
38
40
  self.btn_remove = None
@@ -46,6 +48,7 @@ class PluginManagerWindow(QDialog):
46
48
  self.refresh_plugin_list()
47
49
 
48
50
  def init_ui(self) -> None:
51
+ """Build the plugin manager UI with table, buttons, and drag-and-drop support."""
49
52
  layout = QVBoxLayout(self)
50
53
 
51
54
  lbl_info = QLabel("Drag & Drop .py or .zip files here to install plugins.")
@@ -106,6 +109,7 @@ class PluginManagerWindow(QDialog):
106
109
  layout.addLayout(btn_layout)
107
110
 
108
111
  def refresh_plugin_list(self) -> None:
112
+ """Repopulate the plugin table from the current plugin registry."""
109
113
  self.table.setRowCount(0)
110
114
  plugins = self.plugin_manager.plugins
111
115
 
@@ -146,11 +150,13 @@ class PluginManagerWindow(QDialog):
146
150
  self.table.item(row, 0).setForeground(color)
147
151
 
148
152
  def update_button_state(self) -> None:
153
+ """Enable or disable the Remove button based on table selection."""
149
154
  has_selection = self.table.currentRow() >= 0
150
155
  if hasattr(self, "btn_remove"):
151
156
  self.btn_remove.setEnabled(has_selection)
152
157
 
153
158
  def on_reload(self, silent: bool = False) -> None:
159
+ """Reload all plugins from disk and refresh the table."""
154
160
  # Trigger reload in main manager
155
161
  if self.plugin_manager.main_window:
156
162
  self.plugin_manager.discover_plugins(self.plugin_manager.main_window)
@@ -164,6 +170,7 @@ class PluginManagerWindow(QDialog):
164
170
  self.refresh_plugin_list()
165
171
 
166
172
  def on_remove_plugin(self) -> None:
173
+ """Delete the selected plugin file or folder and reload."""
167
174
  row = self.table.currentRow()
168
175
  if row < 0:
169
176
  QMessageBox.warning(self, "Warning", "Please select a plugin to remove.")
@@ -206,7 +213,7 @@ class PluginManagerWindow(QDialog):
206
213
  "Success",
207
214
  f"Removed '{plugin.get('name', 'Unknown')}'.",
208
215
  )
209
- except (AttributeError, RuntimeError, ValueError) as e:
216
+ except OSError as e:
210
217
  QMessageBox.critical(
211
218
  self, "Error", f"Failed to delete plugin: {e}"
212
219
  )
@@ -216,6 +223,7 @@ class PluginManagerWindow(QDialog):
216
223
  )
217
224
 
218
225
  def show_plugin_details(self, item: QTableWidgetItem) -> None:
226
+ """Show a message box with full metadata for the double-clicked plugin."""
219
227
  row = item.row()
220
228
  if row < len(self.plugin_manager.plugins):
221
229
  p = self.plugin_manager.plugins[row]
@@ -231,6 +239,7 @@ class PluginManagerWindow(QDialog):
231
239
 
232
240
  # --- Drag & Drop Support ---
233
241
  def dragEnterEvent(self, event: Optional[QDragEnterEvent]) -> None:
242
+ """Accept drag events carrying file URLs."""
234
243
  if event is None:
235
244
  return
236
245
  if event.mimeData().hasUrls():
@@ -239,6 +248,7 @@ class PluginManagerWindow(QDialog):
239
248
  event.ignore()
240
249
 
241
250
  def dropEvent(self, event: Optional[QDropEvent]) -> None:
251
+ """Install dropped plugin .py files, folders, or zip archives."""
242
252
  if event is None:
243
253
  return
244
254
  files_installed = []
@@ -30,6 +30,8 @@ from ..utils.constants import VERSION
30
30
 
31
31
 
32
32
  class AboutDialog(QDialog):
33
+ """Dialog showing application version, icon, and project links."""
34
+
33
35
  def __init__(self, main_window: Any, parent: Optional[QWidget] = None) -> None:
34
36
  super().__init__(parent)
35
37
  self.image_label = None
@@ -39,6 +41,7 @@ class AboutDialog(QDialog):
39
41
  self.init_ui()
40
42
 
41
43
  def init_ui(self) -> None:
44
+ """Build the about dialog layout with icon, version text, and links."""
42
45
  layout = QVBoxLayout(self)
43
46
 
44
47
  # Create a clickable image label
@@ -33,6 +33,8 @@ from rdkit.Chem import inchi as rd_inchi
33
33
 
34
34
 
35
35
  class AnalysisWindow(QDialog):
36
+ """Dialog displaying molecular formula, weight, and other computed properties."""
37
+
36
38
  def __init__(
37
39
  self,
38
40
  mol: Chem.Mol,
@@ -47,6 +49,7 @@ class AnalysisWindow(QDialog):
47
49
  self.init_ui()
48
50
 
49
51
  def init_ui(self) -> None:
52
+ """Build the analysis layout with computed molecular properties."""
50
53
  main_layout = QVBoxLayout(self)
51
54
  grid_layout = QGridLayout()
52
55
 
@@ -230,6 +233,7 @@ class AnalysisWindow(QDialog):
230
233
  self.setLayout(main_layout)
231
234
 
232
235
  def copy_to_clipboard(self, text: str) -> None:
236
+ """Copy text to the system clipboard and show a status bar message."""
233
237
  clipboard = QApplication.clipboard()
234
238
  clipboard.setText(text)
235
239
  if self.parent() and hasattr(self.parent(), "statusBar"):