MoleditPy 4.1.3__tar.gz → 4.2.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 (82) hide show
  1. {moleditpy-4.1.3 → moleditpy-4.2.0}/PKG-INFO +6 -6
  2. {moleditpy-4.1.3 → moleditpy-4.2.0}/README.md +2 -2
  3. {moleditpy-4.1.3 → moleditpy-4.2.0}/pyproject.toml +4 -4
  4. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/MoleditPy.egg-info/PKG-INFO +6 -6
  5. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/MoleditPy.egg-info/SOURCES.txt +1 -0
  6. moleditpy-4.2.0/src/MoleditPy.egg-info/requires.txt +11 -0
  7. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/core/mol_geometry.py +61 -4
  8. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/core/molecular_data.py +35 -13
  9. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/main.py +7 -4
  10. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/plugins/plugin_interface.py +2 -2
  11. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/plugins/plugin_manager.py +29 -18
  12. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/plugins/plugin_manager_window.py +6 -7
  13. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/about_dialog.py +1 -1
  14. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/align_plane_dialog.py +3 -3
  15. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/alignment_dialog.py +3 -3
  16. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/analysis_window.py +2 -2
  17. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/angle_dialog.py +16 -15
  18. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/app_state.py +19 -9
  19. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/atom_item.py +9 -6
  20. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/atom_picking.py +3 -3
  21. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/base_picking_dialog.py +5 -4
  22. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/bond_item.py +22 -17
  23. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/bond_length_dialog.py +15 -14
  24. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/calculation_worker.py +29 -34
  25. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/color_settings_dialog.py +11 -11
  26. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/compute_logic.py +97 -58
  27. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/constrained_optimization_dialog.py +1 -1
  28. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/custom_interactor_style.py +27 -16
  29. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/dialog_3d_picking_mixin.py +9 -5
  30. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/dihedral_dialog.py +14 -13
  31. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/edit_3d_logic.py +11 -10
  32. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/edit_actions_logic.py +62 -59
  33. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/export_logic.py +13 -7
  34. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/geometry_base_dialog.py +2 -1
  35. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/io_logic.py +65 -38
  36. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/main_window.py +5 -5
  37. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/main_window_init.py +52 -61
  38. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/mirror_dialog.py +1 -1
  39. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/molecular_scene_handler.py +62 -11
  40. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/molecule_scene.py +12 -14
  41. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/move_group_dialog.py +14 -14
  42. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/move_selected_atoms_dialog.py +14 -15
  43. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/periodic_table_dialog.py +1 -1
  44. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/planarize_dialog.py +7 -5
  45. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/plugin_menu_manager.py +7 -7
  46. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/settings_dialog.py +8 -8
  47. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/settings_tabs/settings_2d_tab.py +9 -9
  48. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/settings_tabs/settings_3d_tabs.py +7 -7
  49. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/settings_tabs/settings_other_tab.py +8 -8
  50. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/settings_tabs/settings_tab_base.py +15 -14
  51. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/translation_dialog.py +11 -11
  52. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/ui_manager.py +6 -6
  53. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/user_template_dialog.py +5 -5
  54. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/view_3d_logic.py +69 -65
  55. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/zoomable_view.py +14 -13
  56. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/utils/constants.py +11 -4
  57. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/utils/sip_isdeleted_safe.py +1 -1
  58. moleditpy-4.2.0/src/moleditpy/utils/suppress_log.py +31 -0
  59. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/utils/system_utils.py +10 -4
  60. moleditpy-4.1.3/src/MoleditPy.egg-info/requires.txt +0 -11
  61. {moleditpy-4.1.3 → moleditpy-4.2.0}/LICENSE +0 -0
  62. {moleditpy-4.1.3 → moleditpy-4.2.0}/setup.cfg +0 -0
  63. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/MoleditPy.egg-info/dependency_links.txt +0 -0
  64. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/MoleditPy.egg-info/entry_points.txt +0 -0
  65. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/MoleditPy.egg-info/top_level.txt +0 -0
  66. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/__init__.py +0 -0
  67. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/__main__.py +0 -0
  68. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/assets/file_icon.ico +0 -0
  69. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/assets/icon.icns +0 -0
  70. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/assets/icon.ico +0 -0
  71. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/assets/icon.png +0 -0
  72. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/core/__init__.py +0 -0
  73. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/plugins/__init__.py +0 -0
  74. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/__init__.py +0 -0
  75. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/custom_qt_interactor.py +0 -0
  76. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/dialog_logic.py +0 -0
  77. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/settings_tabs/__init__.py +0 -0
  78. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/string_importers.py +0 -0
  79. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/template_preview_item.py +0 -0
  80. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/template_preview_view.py +0 -0
  81. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/utils/__init__.py +0 -0
  82. {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/utils/default_settings.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: MoleditPy
3
- Version: 4.1.3
3
+ Version: 4.2.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
@@ -694,9 +694,9 @@ Requires-Python: <3.15,>=3.9
694
694
  Description-Content-Type: text/markdown
695
695
  License-File: LICENSE
696
696
  Requires-Dist: numpy
697
- Requires-Dist: pyqt6<6.10; sys_platform == "darwin"
698
- Requires-Dist: pyqt6<6.11; sys_platform != "darwin"
699
- Requires-Dist: pyvista<0.48
697
+ Requires-Dist: pyqt6<6.10; python_version == "3.9"
698
+ Requires-Dist: pyqt6<6.12; python_version != "3.9"
699
+ Requires-Dist: pyvista<0.49
700
700
  Requires-Dist: pyvistaqt<0.12
701
701
  Requires-Dist: rdkit<2026.4
702
702
  Requires-Dist: openbabel-wheel<3.2
@@ -774,7 +774,7 @@ This application combines a modern GUI built with **PyQt6**, powerful cheminform
774
774
  * Import structures from **MOL/SDF** files or **SMILES** strings.
775
775
  * Export 3D structures to **MOL** or **XYZ** formats, which are compatible with most DFT calculation software.
776
776
  * Export 2D and 3D views as high-resolution PNG images.
777
- * Export 2D and 3D views as high-resolution PNG images.
777
+ * **Security note on `.pmeraw`:** the legacy `.pmeraw` project format uses Python pickle, which can execute arbitrary code when loaded. Only open `.pmeraw` files that you created yourself; prefer the JSON-based `.pmeprj` format for sharing.
778
778
 
779
779
  ### 4. Programmable & Extensible
780
780
 
@@ -909,7 +909,7 @@ Additionally, please cite the plugins you used.
909
909
  * **MOL/SDF**ファイルや**SMILES**文字列から構造をインポートできます。
910
910
  * 3D構造を**MOL**または**XYZ**形式でエクスポートでき、これらは多くのDFT計算ソフトウェアと互換性があります。
911
911
  * 2Dおよび3Dビューを高解像度のPNG画像としてエクスポートできます。
912
- * 2Dおよび3Dビューを高解像度のPNG画像としてエクスポートできます。
912
+ * **`.pmeraw` に関するセキュリティ上の注意:** 旧形式の `.pmeraw` プロジェクトは Python の pickle を使用しており、読み込み時に任意のコードが実行される可能性があります。自分で作成した `.pmeraw` ファイルのみを開き、共有には JSON ベースの `.pmeprj` 形式を使用してください。
913
913
 
914
914
  ### 4. プログラマブルで拡張可能
915
915
 
@@ -70,7 +70,7 @@ This application combines a modern GUI built with **PyQt6**, powerful cheminform
70
70
  * Import structures from **MOL/SDF** files or **SMILES** strings.
71
71
  * Export 3D structures to **MOL** or **XYZ** formats, which are compatible with most DFT calculation software.
72
72
  * Export 2D and 3D views as high-resolution PNG images.
73
- * Export 2D and 3D views as high-resolution PNG images.
73
+ * **Security note on `.pmeraw`:** the legacy `.pmeraw` project format uses Python pickle, which can execute arbitrary code when loaded. Only open `.pmeraw` files that you created yourself; prefer the JSON-based `.pmeprj` format for sharing.
74
74
 
75
75
  ### 4. Programmable & Extensible
76
76
 
@@ -205,7 +205,7 @@ Additionally, please cite the plugins you used.
205
205
  * **MOL/SDF**ファイルや**SMILES**文字列から構造をインポートできます。
206
206
  * 3D構造を**MOL**または**XYZ**形式でエクスポートでき、これらは多くのDFT計算ソフトウェアと互換性があります。
207
207
  * 2Dおよび3Dビューを高解像度のPNG画像としてエクスポートできます。
208
- * 2Dおよび3Dビューを高解像度のPNG画像としてエクスポートできます。
208
+ * **`.pmeraw` に関するセキュリティ上の注意:** 旧形式の `.pmeraw` プロジェクトは Python の pickle を使用しており、読み込み時に任意のコードが実行される可能性があります。自分で作成した `.pmeraw` ファイルのみを開き、共有には JSON ベースの `.pmeprj` 形式を使用してください。
209
209
 
210
210
  ### 4. プログラマブルで拡張可能
211
211
 
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
5
5
  [project]
6
6
  name = "MoleditPy"
7
7
 
8
- version = "4.1.3"
8
+ version = "4.2.0"
9
9
 
10
10
  license = {file = "LICENSE"}
11
11
 
@@ -34,9 +34,9 @@ classifiers = [
34
34
 
35
35
  dependencies = [
36
36
  "numpy",
37
- "pyqt6 < 6.10; sys_platform == 'darwin'",
38
- "pyqt6 < 6.11; sys_platform != 'darwin'",
39
- "pyvista < 0.48",
37
+ "pyqt6 < 6.10; python_version == '3.9'",
38
+ "pyqt6 < 6.12; python_version != '3.9'",
39
+ "pyvista < 0.49",
40
40
  "pyvistaqt < 0.12",
41
41
  "rdkit < 2026.4",
42
42
  "openbabel-wheel < 3.2"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: MoleditPy
3
- Version: 4.1.3
3
+ Version: 4.2.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
@@ -694,9 +694,9 @@ Requires-Python: <3.15,>=3.9
694
694
  Description-Content-Type: text/markdown
695
695
  License-File: LICENSE
696
696
  Requires-Dist: numpy
697
- Requires-Dist: pyqt6<6.10; sys_platform == "darwin"
698
- Requires-Dist: pyqt6<6.11; sys_platform != "darwin"
699
- Requires-Dist: pyvista<0.48
697
+ Requires-Dist: pyqt6<6.10; python_version == "3.9"
698
+ Requires-Dist: pyqt6<6.12; python_version != "3.9"
699
+ Requires-Dist: pyvista<0.49
700
700
  Requires-Dist: pyvistaqt<0.12
701
701
  Requires-Dist: rdkit<2026.4
702
702
  Requires-Dist: openbabel-wheel<3.2
@@ -774,7 +774,7 @@ This application combines a modern GUI built with **PyQt6**, powerful cheminform
774
774
  * Import structures from **MOL/SDF** files or **SMILES** strings.
775
775
  * Export 3D structures to **MOL** or **XYZ** formats, which are compatible with most DFT calculation software.
776
776
  * Export 2D and 3D views as high-resolution PNG images.
777
- * Export 2D and 3D views as high-resolution PNG images.
777
+ * **Security note on `.pmeraw`:** the legacy `.pmeraw` project format uses Python pickle, which can execute arbitrary code when loaded. Only open `.pmeraw` files that you created yourself; prefer the JSON-based `.pmeprj` format for sharing.
778
778
 
779
779
  ### 4. Programmable & Extensible
780
780
 
@@ -909,7 +909,7 @@ Additionally, please cite the plugins you used.
909
909
  * **MOL/SDF**ファイルや**SMILES**文字列から構造をインポートできます。
910
910
  * 3D構造を**MOL**または**XYZ**形式でエクスポートでき、これらは多くのDFT計算ソフトウェアと互換性があります。
911
911
  * 2Dおよび3Dビューを高解像度のPNG画像としてエクスポートできます。
912
- * 2Dおよび3Dビューを高解像度のPNG画像としてエクスポートできます。
912
+ * **`.pmeraw` に関するセキュリティ上の注意:** 旧形式の `.pmeraw` プロジェクトは Python の pickle を使用しており、読み込み時に任意のコードが実行される可能性があります。自分で作成した `.pmeraw` ファイルのみを開き、共有には JSON ベースの `.pmeprj` 形式を使用してください。
913
913
 
914
914
  ### 4. プログラマブルで拡張可能
915
915
 
@@ -75,4 +75,5 @@ src/moleditpy/utils/__init__.py
75
75
  src/moleditpy/utils/constants.py
76
76
  src/moleditpy/utils/default_settings.py
77
77
  src/moleditpy/utils/sip_isdeleted_safe.py
78
+ src/moleditpy/utils/suppress_log.py
78
79
  src/moleditpy/utils/system_utils.py
@@ -0,0 +1,11 @@
1
+ numpy
2
+ pyvista<0.49
3
+ pyvistaqt<0.12
4
+ rdkit<2026.4
5
+ openbabel-wheel<3.2
6
+
7
+ [:python_version != "3.9"]
8
+ pyqt6<6.12
9
+
10
+ [:python_version == "3.9"]
11
+ pyqt6<6.10
@@ -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
 
@@ -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.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.{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
  )