MoleditPy 4.1.4__tar.gz → 4.2.1__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.4 → moleditpy-4.2.1}/PKG-INFO +6 -6
  2. {moleditpy-4.1.4 → moleditpy-4.2.1}/README.md +2 -2
  3. {moleditpy-4.1.4 → moleditpy-4.2.1}/pyproject.toml +4 -4
  4. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/MoleditPy.egg-info/PKG-INFO +6 -6
  5. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/MoleditPy.egg-info/SOURCES.txt +1 -0
  6. moleditpy-4.2.1/src/MoleditPy.egg-info/requires.txt +11 -0
  7. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/core/mol_geometry.py +61 -4
  8. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/core/molecular_data.py +35 -13
  9. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/main.py +7 -4
  10. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/plugins/plugin_interface.py +2 -2
  11. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/plugins/plugin_manager.py +28 -19
  12. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/plugins/plugin_manager_window.py +4 -4
  13. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/ui/about_dialog.py +1 -1
  14. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/ui/align_plane_dialog.py +3 -3
  15. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/ui/alignment_dialog.py +3 -3
  16. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/ui/analysis_window.py +2 -2
  17. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/ui/angle_dialog.py +16 -15
  18. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/ui/app_state.py +19 -9
  19. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/ui/atom_item.py +9 -6
  20. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/ui/atom_picking.py +3 -3
  21. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/ui/base_picking_dialog.py +5 -4
  22. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/ui/bond_item.py +22 -17
  23. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/ui/bond_length_dialog.py +15 -14
  24. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/ui/calculation_worker.py +29 -34
  25. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/ui/color_settings_dialog.py +11 -11
  26. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/ui/compute_logic.py +100 -71
  27. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/ui/constrained_optimization_dialog.py +1 -1
  28. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/ui/custom_interactor_style.py +27 -16
  29. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/ui/dialog_3d_picking_mixin.py +9 -5
  30. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/ui/dihedral_dialog.py +14 -13
  31. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/ui/edit_3d_logic.py +11 -10
  32. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/ui/edit_actions_logic.py +62 -59
  33. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/ui/export_logic.py +13 -7
  34. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/ui/geometry_base_dialog.py +2 -1
  35. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/ui/io_logic.py +65 -38
  36. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/ui/main_window.py +5 -5
  37. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/ui/main_window_init.py +77 -68
  38. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/ui/mirror_dialog.py +1 -1
  39. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/ui/molecular_scene_handler.py +62 -11
  40. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/ui/molecule_scene.py +12 -14
  41. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/ui/move_group_dialog.py +14 -14
  42. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/ui/move_selected_atoms_dialog.py +13 -13
  43. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/ui/periodic_table_dialog.py +1 -1
  44. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/ui/planarize_dialog.py +7 -5
  45. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/ui/plugin_menu_manager.py +19 -7
  46. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/ui/settings_dialog.py +8 -8
  47. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/ui/settings_tabs/settings_2d_tab.py +9 -9
  48. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/ui/settings_tabs/settings_3d_tabs.py +7 -7
  49. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/ui/settings_tabs/settings_other_tab.py +8 -8
  50. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/ui/settings_tabs/settings_tab_base.py +15 -14
  51. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/ui/translation_dialog.py +11 -11
  52. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/ui/ui_manager.py +6 -6
  53. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/ui/user_template_dialog.py +5 -5
  54. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/ui/view_3d_logic.py +69 -65
  55. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/ui/zoomable_view.py +14 -13
  56. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/utils/constants.py +11 -4
  57. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/utils/sip_isdeleted_safe.py +1 -1
  58. moleditpy-4.2.1/src/moleditpy/utils/suppress_log.py +31 -0
  59. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/utils/system_utils.py +10 -4
  60. moleditpy-4.1.4/src/MoleditPy.egg-info/requires.txt +0 -11
  61. {moleditpy-4.1.4 → moleditpy-4.2.1}/LICENSE +0 -0
  62. {moleditpy-4.1.4 → moleditpy-4.2.1}/setup.cfg +0 -0
  63. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/MoleditPy.egg-info/dependency_links.txt +0 -0
  64. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/MoleditPy.egg-info/entry_points.txt +0 -0
  65. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/MoleditPy.egg-info/top_level.txt +0 -0
  66. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/__init__.py +0 -0
  67. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/__main__.py +0 -0
  68. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/assets/file_icon.ico +0 -0
  69. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/assets/icon.icns +0 -0
  70. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/assets/icon.ico +0 -0
  71. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/assets/icon.png +0 -0
  72. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/core/__init__.py +0 -0
  73. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/plugins/__init__.py +0 -0
  74. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/ui/__init__.py +0 -0
  75. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/ui/custom_qt_interactor.py +0 -0
  76. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/ui/dialog_logic.py +0 -0
  77. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/ui/settings_tabs/__init__.py +0 -0
  78. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/ui/string_importers.py +0 -0
  79. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/ui/template_preview_item.py +0 -0
  80. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/ui/template_preview_view.py +0 -0
  81. {moleditpy-4.1.4 → moleditpy-4.2.1}/src/moleditpy/utils/__init__.py +0 -0
  82. {moleditpy-4.1.4 → moleditpy-4.2.1}/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.4
3
+ Version: 4.2.1
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.4"
8
+ version = "4.2.1"
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.4
3
+ Version: 4.2.1
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
 
@@ -249,8 +252,10 @@ class PluginManager:
249
252
  return []
250
253
 
251
254
  for root, dirs, files in os.walk(self.plugin_dir):
252
- # Exclude hidden directories
253
- 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
+ ]
254
259
 
255
260
  # [Check] Is current dir a package (plugin body)?
256
261
  if "__init__.py" in files:
@@ -333,7 +338,7 @@ class PluginManager:
333
338
  sys.modules[spec.name] = module
334
339
 
335
340
  # Inject category info
336
- module.PLUGIN_CATEGORY = category
341
+ module.PLUGIN_CATEGORY = category # type: ignore[attr-defined]
337
342
 
338
343
  spec.loader.exec_module(module)
339
344
 
@@ -480,12 +485,18 @@ class PluginManager:
480
485
  self, plugin_name: str, method_name: str, callback: Callable
481
486
  ) -> None:
482
487
  """Register a named 3D optimization method provided by a plugin."""
483
- # Key by upper-case method name for consistency
484
- self.optimization_methods[method_name.upper()] = {
488
+ method_key = method_name.upper()
489
+ self.optimization_methods[method_key] = {
485
490
  "plugin": plugin_name,
486
491
  "callback": callback,
487
492
  "label": method_name,
488
493
  }
494
+ if hasattr(self.main_window, "init_manager") and hasattr(
495
+ self.main_window.init_manager, "add_optimization_method"
496
+ ):
497
+ self.main_window.init_manager.add_optimization_method(
498
+ method_name, method_key
499
+ )
489
500
 
490
501
  def register_file_opener(
491
502
  self, plugin_name: str, extension: str, callback: Callable, priority: int = 0
@@ -608,10 +619,6 @@ class PluginManager:
608
619
  selected_indices.append(i)
609
620
  except (RuntimeError, ValueError, TypeError):
610
621
  continue
611
- except (
612
- ImportError
613
- ): # [OPTIONAL DEP] importlib.metadata unavailable (<3.8); silently skip.
614
- pass
615
622
  except (RuntimeError, AttributeError) as e:
616
623
  logging.error(f"Error retrieving selected atom indices: {e}")
617
624
 
@@ -660,15 +667,15 @@ class PluginManager:
660
667
  if isinstance(target, ast.Name):
661
668
  # Helper to extract value
662
669
  val = None
663
- if node.value: # AnnAssign might presumably not have value? (though usually does for module globals)
664
- if isinstance(node.value, ast.Constant): # Py3.8+
665
- val = node.value.value
666
- elif isinstance(node.value, ast.Tuple):
670
+ if node.value: # type: ignore[attr-defined] # AnnAssign might presumably not have value? (though usually does for module globals)
671
+ if isinstance(node.value, ast.Constant): # type: ignore[attr-defined] # Py3.8+
672
+ val = node.value.value # type: ignore[attr-defined]
673
+ elif isinstance(node.value, ast.Tuple): # type: ignore[attr-defined]
667
674
  # Handle version tuples e.g. (1, 0, 0)
668
675
  try:
669
676
  # Extract simple constants from tuple
670
677
  elts = []
671
- for elt in node.value.elts:
678
+ for elt in node.value.elts: # type: ignore[attr-defined]
672
679
  if isinstance(elt, ast.Constant):
673
680
  elts.append(elt.value)
674
681
  val = ".".join(map(str, elts))
@@ -678,7 +685,9 @@ class PluginManager:
678
685
  ValueError,
679
686
  TypeError,
680
687
  ): # [AST PARSE] Complex/unexpected AST node shapes during metadata extraction; skip gracefully.
681
- pass
688
+ logging.debug(
689
+ "Suppressed non-critical error", exc_info=True
690
+ )
682
691
 
683
692
  if val is not None:
684
693
  if target.id == "PLUGIN_NAME":
@@ -712,7 +721,7 @@ class PluginManager:
712
721
  elif hasattr(ast, "Str") and isinstance(
713
722
  node.value, getattr(ast, "Str", type(None))
714
723
  ):
715
- val = node.value.s
724
+ val = node.value.s # type: ignore[attr-defined]
716
725
 
717
726
  if val and not info["description"]:
718
727
  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)
@@ -241,7 +241,7 @@ class PluginManagerWindow(QDialog):
241
241
  """Accept drag events carrying file URLs."""
242
242
  if event is None:
243
243
  return
244
- if event.mimeData().hasUrls():
244
+ if event.mimeData().hasUrls(): # type: ignore[union-attr]
245
245
  event.accept()
246
246
  else:
247
247
  event.ignore()
@@ -252,7 +252,7 @@ class PluginManagerWindow(QDialog):
252
252
  return
253
253
  files_installed = []
254
254
  errors = []
255
- for url in event.mimeData().urls():
255
+ for url in event.mimeData().urls(): # type: ignore[union-attr]
256
256
  file_path = url.toLocalFile()
257
257
 
258
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
  )