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.
- {moleditpy-4.1.3 → moleditpy-4.2.0}/PKG-INFO +6 -6
- {moleditpy-4.1.3 → moleditpy-4.2.0}/README.md +2 -2
- {moleditpy-4.1.3 → moleditpy-4.2.0}/pyproject.toml +4 -4
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/MoleditPy.egg-info/PKG-INFO +6 -6
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/MoleditPy.egg-info/SOURCES.txt +1 -0
- moleditpy-4.2.0/src/MoleditPy.egg-info/requires.txt +11 -0
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/core/mol_geometry.py +61 -4
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/core/molecular_data.py +35 -13
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/main.py +7 -4
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/plugins/plugin_interface.py +2 -2
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/plugins/plugin_manager.py +29 -18
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/plugins/plugin_manager_window.py +6 -7
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/about_dialog.py +1 -1
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/align_plane_dialog.py +3 -3
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/alignment_dialog.py +3 -3
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/analysis_window.py +2 -2
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/angle_dialog.py +16 -15
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/app_state.py +19 -9
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/atom_item.py +9 -6
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/atom_picking.py +3 -3
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/base_picking_dialog.py +5 -4
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/bond_item.py +22 -17
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/bond_length_dialog.py +15 -14
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/calculation_worker.py +29 -34
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/color_settings_dialog.py +11 -11
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/compute_logic.py +97 -58
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/constrained_optimization_dialog.py +1 -1
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/custom_interactor_style.py +27 -16
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/dialog_3d_picking_mixin.py +9 -5
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/dihedral_dialog.py +14 -13
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/edit_3d_logic.py +11 -10
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/edit_actions_logic.py +62 -59
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/export_logic.py +13 -7
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/geometry_base_dialog.py +2 -1
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/io_logic.py +65 -38
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/main_window.py +5 -5
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/main_window_init.py +52 -61
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/mirror_dialog.py +1 -1
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/molecular_scene_handler.py +62 -11
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/molecule_scene.py +12 -14
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/move_group_dialog.py +14 -14
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/move_selected_atoms_dialog.py +14 -15
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/periodic_table_dialog.py +1 -1
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/planarize_dialog.py +7 -5
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/plugin_menu_manager.py +7 -7
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/settings_dialog.py +8 -8
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/settings_tabs/settings_2d_tab.py +9 -9
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/settings_tabs/settings_3d_tabs.py +7 -7
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/settings_tabs/settings_other_tab.py +8 -8
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/settings_tabs/settings_tab_base.py +15 -14
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/translation_dialog.py +11 -11
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/ui_manager.py +6 -6
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/user_template_dialog.py +5 -5
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/view_3d_logic.py +69 -65
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/zoomable_view.py +14 -13
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/utils/constants.py +11 -4
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/utils/sip_isdeleted_safe.py +1 -1
- moleditpy-4.2.0/src/moleditpy/utils/suppress_log.py +31 -0
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/utils/system_utils.py +10 -4
- moleditpy-4.1.3/src/MoleditPy.egg-info/requires.txt +0 -11
- {moleditpy-4.1.3 → moleditpy-4.2.0}/LICENSE +0 -0
- {moleditpy-4.1.3 → moleditpy-4.2.0}/setup.cfg +0 -0
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/MoleditPy.egg-info/dependency_links.txt +0 -0
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/MoleditPy.egg-info/entry_points.txt +0 -0
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/MoleditPy.egg-info/top_level.txt +0 -0
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/__init__.py +0 -0
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/__main__.py +0 -0
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/assets/file_icon.ico +0 -0
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/assets/icon.icns +0 -0
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/assets/icon.ico +0 -0
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/assets/icon.png +0 -0
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/core/__init__.py +0 -0
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/plugins/__init__.py +0 -0
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/__init__.py +0 -0
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/custom_qt_interactor.py +0 -0
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/dialog_logic.py +0 -0
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/settings_tabs/__init__.py +0 -0
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/string_importers.py +0 -0
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/template_preview_item.py +0 -0
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/ui/template_preview_view.py +0 -0
- {moleditpy-4.1.3 → moleditpy-4.2.0}/src/moleditpy/utils/__init__.py +0 -0
- {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.
|
|
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;
|
|
698
|
-
Requires-Dist: pyqt6<6.
|
|
699
|
-
Requires-Dist: pyvista<0.
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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.
|
|
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;
|
|
38
|
-
"pyqt6 < 6.
|
|
39
|
-
"pyvista < 0.
|
|
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.
|
|
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;
|
|
698
|
-
Requires-Dist: pyqt6<6.
|
|
699
|
-
Requires-Dist: pyvista<0.
|
|
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
|
-
*
|
|
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
|
-
*
|
|
912
|
+
* **`.pmeraw` に関するセキュリティ上の注意:** 旧形式の `.pmeraw` プロジェクトは Python の pickle を使用しており、読み込み時に任意のコードが実行される可能性があります。自分で作成した `.pmeraw` ファイルのみを開き、共有には JSON ベースの `.pmeprj` 形式を使用してください。
|
|
913
913
|
|
|
914
914
|
### 4. プログラマブルで拡張可能
|
|
915
915
|
|
|
@@ -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
|
-
|
|
622
|
-
|
|
623
|
-
parent[
|
|
624
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
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
|
-
|
|
283
|
-
stereo_atoms_specified =
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
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[:] = [
|
|
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
|
-
|
|
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:
|
|
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)
|
|
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:
|
|
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)
|
|
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
|
)
|