moleditpy 5.0.2__tar.gz → 5.0.3__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 (99) hide show
  1. {moleditpy-5.0.2 → moleditpy-5.0.3}/PKG-INFO +2 -2
  2. {moleditpy-5.0.2 → moleditpy-5.0.3}/pyproject.toml +2 -2
  3. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/MoleditPy.egg-info/PKG-INFO +2 -2
  4. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/MoleditPy.egg-info/SOURCES.txt +1 -0
  5. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/MoleditPy.egg-info/requires.txt +1 -1
  6. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/core/molecular_data.py +1 -0
  7. moleditpy-5.0.3/src/moleditpy/core/project_validation.py +117 -0
  8. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/plugins/plugin_interface.py +5 -2
  9. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/plugins/plugin_manager.py +13 -0
  10. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/alignment_dialog.py +10 -1
  11. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/compute_logic.py +19 -0
  12. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/export_logic.py +5 -11
  13. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/geometry_base_dialog.py +6 -3
  14. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/io_logic.py +21 -6
  15. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/main_window_init.py +3 -3
  16. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/molecular_scene_handler.py +4 -0
  17. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/preview_molecule.py +2 -0
  18. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/template_preview_item.py +1 -0
  19. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/ui_manager.py +3 -12
  20. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/user_template_dialog.py +3 -0
  21. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/utils/default_settings.py +48 -0
  22. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/utils/label_style.py +9 -3
  23. {moleditpy-5.0.2 → moleditpy-5.0.3}/COPYING +0 -0
  24. {moleditpy-5.0.2 → moleditpy-5.0.3}/DISTRIBUTION.md +0 -0
  25. {moleditpy-5.0.2 → moleditpy-5.0.3}/LICENSE +0 -0
  26. {moleditpy-5.0.2 → moleditpy-5.0.3}/LICENSING.md +0 -0
  27. {moleditpy-5.0.2 → moleditpy-5.0.3}/README.md +0 -0
  28. {moleditpy-5.0.2 → moleditpy-5.0.3}/THIRD_PARTY_NOTICES.md +0 -0
  29. {moleditpy-5.0.2 → moleditpy-5.0.3}/setup.cfg +0 -0
  30. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/MoleditPy.egg-info/dependency_links.txt +0 -0
  31. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/MoleditPy.egg-info/entry_points.txt +0 -0
  32. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/MoleditPy.egg-info/top_level.txt +0 -0
  33. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/__init__.py +0 -0
  34. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/__main__.py +0 -0
  35. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/assets/file_icon.ico +0 -0
  36. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/assets/icon.icns +0 -0
  37. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/assets/icon.ico +0 -0
  38. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/assets/icon.png +0 -0
  39. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/core/__init__.py +0 -0
  40. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/core/mol_geometry.py +0 -0
  41. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/core/stereo_check.py +0 -0
  42. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/main.py +0 -0
  43. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/plugins/__init__.py +0 -0
  44. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/plugins/plugin_manager_window.py +0 -0
  45. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/__init__.py +0 -0
  46. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/about_dialog.py +0 -0
  47. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/align_plane_dialog.py +0 -0
  48. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/analysis_window.py +0 -0
  49. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/angle_dialog.py +0 -0
  50. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/app_state.py +0 -0
  51. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/atom_item.py +0 -0
  52. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/atom_picking.py +0 -0
  53. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/base_picking_dialog.py +0 -0
  54. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/bond_item.py +0 -0
  55. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/bond_length_dialog.py +0 -0
  56. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/calculation_worker.py +0 -0
  57. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/chain_mixin.py +0 -0
  58. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/chirality_warning_dialog.py +0 -0
  59. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/color_settings_dialog.py +0 -0
  60. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/constrained_optimization_dialog.py +0 -0
  61. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/custom_interactor_style.py +0 -0
  62. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/custom_qt_interactor.py +0 -0
  63. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/dialog_3d_picking_mixin.py +0 -0
  64. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/dialog_logic.py +0 -0
  65. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/dihedral_dialog.py +0 -0
  66. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/edit_3d_logic.py +0 -0
  67. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/edit_actions_logic.py +0 -0
  68. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/main_window.py +0 -0
  69. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/menu_utils.py +0 -0
  70. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/mirror_dialog.py +0 -0
  71. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/molecule_scene.py +0 -0
  72. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/move_dialog_mixin.py +0 -0
  73. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/move_group_dialog.py +0 -0
  74. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/move_selected_atoms_dialog.py +0 -0
  75. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/periodic_table_dialog.py +0 -0
  76. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/planarize_dialog.py +0 -0
  77. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/plugin_menu_manager.py +0 -0
  78. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/sdf_record_dialog.py +0 -0
  79. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/settings_dialog.py +0 -0
  80. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/settings_tabs/__init__.py +0 -0
  81. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/settings_tabs/settings_2d_cleanup_tab.py +0 -0
  82. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/settings_tabs/settings_2d_tab.py +0 -0
  83. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/settings_tabs/settings_3d_tabs.py +0 -0
  84. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/settings_tabs/settings_labels_tab.py +0 -0
  85. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/settings_tabs/settings_other_tab.py +0 -0
  86. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/settings_tabs/settings_tab_base.py +0 -0
  87. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/string_importers.py +0 -0
  88. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/template_preview_view.py +0 -0
  89. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/translation_dialog.py +0 -0
  90. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/view_3d_logic.py +0 -0
  91. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/ui/zoomable_view.py +0 -0
  92. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/utils/__init__.py +0 -0
  93. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/utils/constants.py +0 -0
  94. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/utils/finite_float.py +0 -0
  95. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/utils/hit_radius.py +0 -0
  96. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/utils/sdf_records.py +0 -0
  97. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/utils/sip_isdeleted_safe.py +0 -0
  98. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/utils/suppress_log.py +0 -0
  99. {moleditpy-5.0.2 → moleditpy-5.0.3}/src/moleditpy/utils/system_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: MoleditPy
3
- Version: 5.0.2
3
+ Version: 5.0.3
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-Expression: LGPL-3.0-or-later
@@ -28,7 +28,7 @@ Requires-Dist: pyside6-essentials<6.10,>=6.9; python_version < "3.10"
28
28
  Requires-Dist: pyside6-essentials<6.12,>=6.10; python_version >= "3.10"
29
29
  Requires-Dist: pyvista<0.50
30
30
  Requires-Dist: pyvistaqt<0.14
31
- Requires-Dist: rdkit<2026.4
31
+ Requires-Dist: rdkit<2026.10
32
32
  Dynamic: license-file
33
33
 
34
34
  # MoleditPy — A Python Molecular Editor
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
5
5
  [project]
6
6
  name = "MoleditPy"
7
7
 
8
- version = "5.0.2"
8
+ version = "5.0.3"
9
9
 
10
10
  license = "LGPL-3.0-or-later"
11
11
  license-files = ["LICENSE", "COPYING", "THIRD_PARTY_NOTICES.md", "DISTRIBUTION.md", "LICENSING.md"]
@@ -39,7 +39,7 @@ dependencies = [
39
39
  "pyside6-essentials >= 6.10, < 6.12; python_version >= '3.10'",
40
40
  "pyvista < 0.50",
41
41
  "pyvistaqt < 0.14",
42
- "rdkit < 2026.4"
42
+ "rdkit < 2026.10"
43
43
  ]
44
44
 
45
45
  [project.urls]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: MoleditPy
3
- Version: 5.0.2
3
+ Version: 5.0.3
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-Expression: LGPL-3.0-or-later
@@ -28,7 +28,7 @@ Requires-Dist: pyside6-essentials<6.10,>=6.9; python_version < "3.10"
28
28
  Requires-Dist: pyside6-essentials<6.12,>=6.10; python_version >= "3.10"
29
29
  Requires-Dist: pyvista<0.50
30
30
  Requires-Dist: pyvistaqt<0.14
31
- Requires-Dist: rdkit<2026.4
31
+ Requires-Dist: rdkit<2026.10
32
32
  Dynamic: license-file
33
33
 
34
34
  # MoleditPy — A Python Molecular Editor
@@ -21,6 +21,7 @@ src/moleditpy/assets/icon.png
21
21
  src/moleditpy/core/__init__.py
22
22
  src/moleditpy/core/mol_geometry.py
23
23
  src/moleditpy/core/molecular_data.py
24
+ src/moleditpy/core/project_validation.py
24
25
  src/moleditpy/core/stereo_check.py
25
26
  src/moleditpy/plugins/__init__.py
26
27
  src/moleditpy/plugins/plugin_interface.py
@@ -1,7 +1,7 @@
1
1
  numpy
2
2
  pyvista<0.50
3
3
  pyvistaqt<0.14
4
- rdkit<2026.4
4
+ rdkit<2026.10
5
5
 
6
6
  [:python_version < "3.10"]
7
7
  pyside6-essentials<6.10,>=6.9
@@ -886,6 +886,7 @@ class MolecularData:
886
886
  "y": pos[1],
887
887
  "charge": atom_info.get("charge", 0),
888
888
  "radical": atom_info.get("radical", 0),
889
+ **rs_entry(atom_info),
889
890
  }
890
891
  )
891
892
 
@@ -0,0 +1,117 @@
1
+ """Validate project contents before replacing the live document."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import math
7
+ from typing import Any
8
+
9
+ import numpy as np
10
+ from rdkit import Chem
11
+
12
+
13
+ def _integer(value: Any, minimum: int = 0) -> int:
14
+ if not isinstance(value, int) or isinstance(value, bool) or value < minimum:
15
+ raise ValueError("Project integer field is invalid")
16
+ return value
17
+
18
+
19
+ def _coordinate(value: Any) -> None:
20
+ try:
21
+ if isinstance(value, bool) or not math.isfinite(float(value)):
22
+ raise ValueError("Project coordinates must be finite numbers")
23
+ except OverflowError as exc:
24
+ raise ValueError("Project coordinate is out of range") from exc
25
+
26
+
27
+ def _validate_2d(structure: Any) -> None:
28
+ if not isinstance(structure, dict):
29
+ raise ValueError("2D structure must be an object")
30
+ atoms, bonds = structure.get("atoms", []), structure.get("bonds", [])
31
+ if not isinstance(atoms, list) or not isinstance(bonds, list):
32
+ raise ValueError("Project atoms and bonds must be lists")
33
+ ids: set[int] = set()
34
+ for atom in atoms:
35
+ atom_id = _integer(atom["id"])
36
+ if atom_id in ids or not isinstance(atom["symbol"], str) or not atom["symbol"]:
37
+ raise ValueError("Project atoms need unique IDs and element symbols")
38
+ ids.add(atom_id)
39
+ _coordinate(atom["x"])
40
+ _coordinate(atom["y"])
41
+ _integer(atom.get("charge", 0), -(2**31))
42
+ if atom.get("charge", 0) >= 2**31:
43
+ raise ValueError("Project charge is out of range")
44
+ _integer(atom.get("radical", 0))
45
+ if atom.get("rs") not in (None, "R", "S"):
46
+ raise ValueError("Project R/S label must be R or S")
47
+ seen: set[frozenset[int]] = set()
48
+ for bond in bonds:
49
+ first, second = _integer(bond["atom1"]), _integer(bond["atom2"])
50
+ pair = frozenset((first, second))
51
+ if first == second or first not in ids or second not in ids or pair in seen:
52
+ raise ValueError("Project bond has invalid atom references")
53
+ seen.add(pair)
54
+ if isinstance(bond["order"], bool) or bond["order"] not in (1, 1.5, 2, 3):
55
+ raise ValueError("Project bond order is invalid")
56
+ if _integer(bond.get("stereo", 0)) not in (0, 1, 2, 3, 4):
57
+ raise ValueError("Project bond stereochemistry is invalid")
58
+ minimum = max(ids) + 1 if ids else 0
59
+ _integer(structure.get("next_atom_id", minimum), minimum)
60
+
61
+
62
+ def _validate_molecule(binary: Any) -> Chem.Mol:
63
+ mol = Chem.Mol(binary)
64
+ for conformer in mol.GetConformers():
65
+ if not np.isfinite(conformer.GetPositions()).all():
66
+ raise ValueError("Project 3D coordinates must be finite")
67
+ return mol
68
+
69
+
70
+ def validate_project_data(data: dict[str, Any]) -> None:
71
+ """Reject corrupt geometry without changing application or plugin state."""
72
+ if "2d_structure" in data:
73
+ _validate_2d(data["2d_structure"])
74
+ structure = data.get("3d_structure")
75
+ if structure is None:
76
+ return
77
+ if not isinstance(structure, dict):
78
+ raise ValueError("3D structure must be an object")
79
+ atoms = structure.get("atoms", [])
80
+ if not isinstance(atoms, list):
81
+ raise ValueError("3D atoms must be a list")
82
+ encoded = structure.get("mol_binary_base64")
83
+ if encoded:
84
+ binary: Any = base64.b64decode(encoded.encode("ascii"), validate=True)
85
+ mol = _validate_molecule(binary)
86
+ if atoms and len(atoms) != mol.GetNumAtoms():
87
+ raise ValueError("3D atom metadata does not match its molecule")
88
+ seen: set[int] = set()
89
+ for atom in atoms:
90
+ index = _integer(atom["index"])
91
+ if index >= len(atoms) or index in seen:
92
+ raise ValueError("3D atom index is invalid")
93
+ seen.add(index)
94
+ for axis in ("x", "y", "z"):
95
+ _coordinate(atom.get(axis, 0.0))
96
+
97
+
98
+ def validate_raw_project_data(data: Any) -> None:
99
+ """Validate legacy pickle geometry before any live state is cleared."""
100
+ if not isinstance(data, dict):
101
+ raise ValueError("Raw project must be an object")
102
+ atoms, bonds = data.get("atoms", {}), data.get("bonds", {})
103
+ if not isinstance(atoms, dict) or not isinstance(bonds, dict):
104
+ raise ValueError("Raw project atoms and bonds must be mappings")
105
+ structure = {
106
+ "atoms": [
107
+ dict(atom, id=atom_id, x=atom["pos"][0], y=atom["pos"][1])
108
+ for atom_id, atom in atoms.items()
109
+ ],
110
+ "bonds": [
111
+ dict(bond, atom1=key[0], atom2=key[1]) for key, bond in bonds.items()
112
+ ],
113
+ "next_atom_id": data.get("_next_atom_id", max(atoms) + 1 if atoms else 0),
114
+ }
115
+ _validate_2d(structure)
116
+ if data.get("mol_3d") is not None:
117
+ _validate_molecule(data["mol_3d"])
@@ -227,7 +227,7 @@ class PluginContext:
227
227
  """Set active RDKit molecule and redraw 3D view."""
228
228
  mw = self.get_main_window()
229
229
  if mw and hasattr(mw, "view_3d_manager"):
230
- mw.view_3d_manager.current_mol = mol
230
+ mw.set_current_molecule(mol)
231
231
  mw.view_3d_manager.draw_molecule_3d(mol)
232
232
 
233
233
  @property
@@ -613,7 +613,10 @@ class PluginContext:
613
613
 
614
614
  for i in range(num_atoms):
615
615
  pos = conf.GetAtomPosition(i)
616
- symbol = mol.GetAtomWithIdx(i).GetSymbol()
616
+ atom = mol.GetAtomWithIdx(i)
617
+ symbol = atom.GetSymbol()
618
+ if atom.GetAtomicNum() == 0 and atom.HasProp("xyz_original_symbol"):
619
+ symbol = atom.GetProp("xyz_original_symbol")
617
620
  xyz_lines.append(
618
621
  f" {symbol:<5}{pos.x:>15.8f}{pos.y:>15.8f}{pos.z:>15.8f}"
619
622
  )
@@ -260,6 +260,19 @@ class PluginManager:
260
260
  if os.path.isdir(file_path):
261
261
  # Copy entire directory
262
262
  dest_path = self._safe_install_path(self.plugin_dir, filename)
263
+ source = os.path.normcase(os.path.realpath(file_path))
264
+ destination = os.path.normcase(os.path.realpath(dest_path))
265
+ if source == destination:
266
+ raise ValueError(
267
+ "Source and installed package are the same directory"
268
+ )
269
+ if (
270
+ os.path.splitdrive(source)[0] == os.path.splitdrive(destination)[0]
271
+ and os.path.commonpath([source, destination]) == source
272
+ ):
273
+ raise ValueError(
274
+ "Cannot install a package inside its source directory"
275
+ )
263
276
  if os.path.exists(dest_path):
264
277
  # Option 1: Overwrite (remove then copy) - safer for clean install
265
278
  if os.path.isdir(dest_path):
@@ -55,6 +55,7 @@ class AlignmentDialog(QDialog, Dialog3DPickingMixin):
55
55
  Dialog3DPickingMixin.__init__(self)
56
56
  self.mol = mol
57
57
  self.main_window = main_window
58
+ self._invalidated = False
58
59
  self.axis = axis
59
60
  self._selected_atoms = SelectionList()
60
61
 
@@ -133,7 +134,7 @@ class AlignmentDialog(QDialog, Dialog3DPickingMixin):
133
134
 
134
135
  def on_atom_picked(self, atom_idx: int) -> None:
135
136
  """Handle atom selection event."""
136
- if self.main_window.view_3d_manager.current_mol is None:
137
+ if self._invalidated or self.main_window.view_3d_manager.current_mol is None:
137
138
  return
138
139
 
139
140
  if atom_idx in self.selected_atoms:
@@ -181,8 +182,16 @@ class AlignmentDialog(QDialog, Dialog3DPickingMixin):
181
182
  for i, idx in enumerate(self.selected_atoms, 1):
182
183
  self.add_selection_label(idx, f"#{i}")
183
184
 
185
+ def invalidate_molecule(self) -> None:
186
+ """Close the tool before undo, import or calculation replaces its molecule."""
187
+ self._invalidated = True
188
+ self.setEnabled(False)
189
+ self.reject()
190
+
184
191
  def apply_alignment(self) -> None:
185
192
  """Apply the specific axial alignment to the molecule."""
193
+ if self._invalidated:
194
+ return
186
195
  if len(self.selected_atoms) != 2:
187
196
  QMessageBox.warning(
188
197
  self, "Warning", "Please select exactly 2 atoms for alignment."
@@ -90,6 +90,12 @@ class _CalculationReceiver(QObject):
90
90
  # _retain_until_destroyed). Releasing it here would let shiboken
91
91
  # delete this receiver inside its own queued slot call.
92
92
  self.deleteLater()
93
+ if (
94
+ self.manager._close_after_calculation
95
+ and not self.manager._active_calc_threads
96
+ ):
97
+ self.manager._close_after_calculation = False
98
+ QTimer.singleShot(0, self.manager.host.close)
93
99
 
94
100
 
95
101
  class ComputeManager:
@@ -103,6 +109,7 @@ class ComputeManager:
103
109
  self.host = host
104
110
  self.last_successful_optimization_method: Optional[str] = None
105
111
  self._active_calc_threads: List[QThread] = []
112
+ self._close_after_calculation = False
106
113
  # id(QObject) -> Python objects kept alive until that QObject is destroyed.
107
114
  self._calc_objects: Dict[int, Tuple[Any, ...]] = {}
108
115
  self.halt_ids: Set[int] = set()
@@ -120,6 +127,18 @@ class ComputeManager:
120
127
  """Reset active calculation threads list."""
121
128
  self._active_calc_threads = []
122
129
 
130
+ def prepare_calculation_shutdown(self) -> bool:
131
+ """Cancel calculations and defer window destruction until Qt finishes them."""
132
+ running = [thread for thread in self._active_calc_threads if thread.isRunning()]
133
+ if not running:
134
+ return True
135
+ self._close_after_calculation = True
136
+ self.halt_conversion()
137
+ for thread in running:
138
+ thread.requestInterruption()
139
+ self.host.update_status_message("Canceling calculation before closing...")
140
+ return False
141
+
123
142
  def _safe_disconnect(self, signal: Any) -> None:
124
143
  """Disconnect a signal, tolerating one that has nothing connected."""
125
144
  try:
@@ -533,6 +533,7 @@ class ExportManager:
533
533
  # This allows glyphs (where all atoms are in one mesh)
534
534
  # to retain individual atom colors when exporting to OBJ/MTL.
535
535
  try:
536
+ color_mesh = mesh_copy
536
537
  colors = None
537
538
  pd = mesh_copy.point_data
538
539
  # Use red/green/blue arrays if available
@@ -559,10 +560,10 @@ class ExportManager:
559
560
  if colors is None and "colors" in mesh_copy.cell_data:
560
561
  try:
561
562
  # Convert cell_data to point_data
562
- temp_mesh = mesh_copy.cell_data_to_point_data()
563
- if "colors" in temp_mesh.point_data:
563
+ color_mesh = mesh_copy.cell_data_to_point_data()
564
+ if "colors" in color_mesh.point_data:
564
565
  colors = np.asarray(
565
- temp_mesh.point_data["colors"]
566
+ color_mesh.point_data["colors"]
566
567
  )
567
568
  except (
568
569
  AttributeError,
@@ -616,15 +617,8 @@ class ExportManager:
616
617
  if point_inds.size == 0:
617
618
  continue
618
619
  try:
619
- # Use temp_mesh if point data available
620
- target_mesh = (
621
- temp_mesh
622
- if "temp_mesh" in locals()
623
- else mesh_copy
624
- )
625
-
626
620
  # extract_points with adjacent_cells=False to avoid pulling in neighbors
627
- submesh = target_mesh.extract_points(
621
+ submesh = color_mesh.extract_points(
628
622
  point_inds, adjacent_cells=False
629
623
  )
630
624
 
@@ -98,9 +98,12 @@ class GeometryBaseDialog(BasePickingDialog):
98
98
  if wrap:
99
99
  f_val = (f_val + 180) % 360 - 180
100
100
 
101
- slider.blockSignals(True)
102
- slider.setValue(int(round(f_val * scale)))
103
- slider.blockSignals(False)
101
+ scaled = max(slider.minimum(), min(slider.maximum(), f_val * scale))
102
+ was_blocked = slider.blockSignals(True)
103
+ try:
104
+ slider.setValue(int(round(scaled)))
105
+ finally:
106
+ slider.blockSignals(was_blocked)
104
107
  except (ValueError, TypeError):
105
108
  # Safe defensive fallback catching ValueError, TypeError
106
109
  logging.debug("Suppressed non-critical error", exc_info=True)
@@ -13,6 +13,7 @@ DOI: 10.5281/zenodo.17268532
13
13
  from __future__ import annotations
14
14
 
15
15
  import logging
16
+ import math
16
17
  import os
17
18
  import json
18
19
  import pickle
@@ -48,6 +49,8 @@ from rdkit.Chem import (
48
49
  )
49
50
 
50
51
  from ..utils.sdf_records import read_sdf_records
52
+ from ..utils.finite_float import finite_float
53
+ from ..core.project_validation import validate_project_data, validate_raw_project_data
51
54
  from .sdf_record_dialog import SdfRecordDialog
52
55
  from ..utils.constants import (
53
56
  COVALENT_RADII,
@@ -242,11 +245,14 @@ class IOManager:
242
245
  nums: list[float] = []
243
246
  for tok in tokens:
244
247
  try:
245
- nums.append(float(tok))
248
+ value = float(tok)
246
249
  except ValueError:
247
250
  if nums:
248
251
  break # trailing non-numeric column after the coordinates
249
252
  continue # leading label/separator column before the coordinates
253
+ if not math.isfinite(value):
254
+ return None
255
+ nums.append(value)
250
256
  if len(nums) == 3:
251
257
  break
252
258
  if len(nums) == 3:
@@ -521,11 +527,17 @@ class IOManager:
521
527
  layout.addLayout(hl)
522
528
  result = {"accepted": False, "skip": False}
523
529
 
530
+ def _charge_value() -> int:
531
+ value = finite_float(line_edit.text().strip() or "0")
532
+ if not value.is_integer() or not -(2**31) <= value < 2**31:
533
+ raise ValueError("Charge must be a supported integer")
534
+ return int(value)
535
+
524
536
  def _accept_if_valid() -> None:
525
537
  """Validate charge input and accept the dialog if valid."""
526
538
  # Invalid input: show inline error and keep the dialog open
527
539
  try:
528
- int(float(line_edit.text().strip() or "0"))
540
+ _charge_value()
529
541
  except ValueError:
530
542
  error_label.setText("Invalid charge: enter an integer (e.g. 0, -1, 2).")
531
543
  dialog.adjustSize()
@@ -544,7 +556,7 @@ class IOManager:
544
556
  if result["skip"]:
545
557
  return 0, True, True
546
558
  try:
547
- val = int(float(line_edit.text().strip() or "0"))
559
+ val = _charge_value()
548
560
  return val, True, False
549
561
  except ValueError:
550
562
  # Unreachable via OK (validated inline)
@@ -824,6 +836,7 @@ class IOManager:
824
836
  )
825
837
  return
826
838
 
839
+ validate_project_data(json_data)
827
840
  if not self.host.edit_actions_manager.clear_all(skip_check=True):
828
841
  return
829
842
 
@@ -1251,9 +1264,10 @@ class IOManager:
1251
1264
  )
1252
1265
  for i in range(num_atoms):
1253
1266
  pos = conf.GetAtomPosition(i)
1254
- symbol = self.host.view_3d_manager.current_mol.GetAtomWithIdx(
1255
- i
1256
- ).GetSymbol()
1267
+ atom = self.host.view_3d_manager.current_mol.GetAtomWithIdx(i)
1268
+ symbol = atom.GetSymbol()
1269
+ if atom.GetAtomicNum() == 0 and atom.HasProp("xyz_original_symbol"):
1270
+ symbol = atom.GetProp("xyz_original_symbol")
1257
1271
  xyz_lines.append(
1258
1272
  f" {symbol:<5}{pos.x:>15.8f}{pos.y:>15.8f}{pos.z:>15.8f}"
1259
1273
  )
@@ -1311,6 +1325,7 @@ class IOManager:
1311
1325
  # Unpickle before clearing, so an unreadable file keeps the document.
1312
1326
  with open(file_path, "rb") as f:
1313
1327
  loaded_data = pickle.load(f)
1328
+ validate_raw_project_data(loaded_data)
1314
1329
  if not self.host.edit_actions_manager.clear_all(skip_check=True):
1315
1330
  return
1316
1331
  self.host.ui_manager.restore_ui_for_editing()
@@ -51,7 +51,7 @@ from PySide6.QtWidgets import (
51
51
  QWidget,
52
52
  )
53
53
 
54
- from ..utils.default_settings import DEFAULT_SETTINGS
54
+ from ..utils.default_settings import normalize_settings
55
55
  from ..plugins.plugin_manager import PluginManager
56
56
  from ..utils.system_utils import detect_system_dark_mode
57
57
 
@@ -552,7 +552,7 @@ class MainInitManager:
552
552
  self._migrate_legacy_settings(loaded_settings)
553
553
 
554
554
  # 4. Update settings with loaded values
555
- self.settings.update(loaded_settings)
555
+ self.settings = normalize_settings(loaded_settings)
556
556
  except (AttributeError, RuntimeError, ValueError, TypeError, IOError):
557
557
  # Use defaults on any error
558
558
  # Safe defensive fallback catching AttributeError, RuntimeError, ValueError, TypeError, IOError
@@ -633,7 +633,7 @@ class MainInitManager:
633
633
  # --- Settings and Plugin Helpers ---
634
634
  def _get_default_settings(self) -> Dict[str, Any]:
635
635
  """Return a dictionary of default application settings."""
636
- return DEFAULT_SETTINGS.copy()
636
+ return normalize_settings({})
637
637
 
638
638
  def _migrate_legacy_settings(self, loaded_settings: Dict[str, Any]) -> None:
639
639
  """Handle migration of legacy settings keys."""
@@ -48,6 +48,7 @@ class TemplateMixin:
48
48
  mode: Any
49
49
  removeItem: Any
50
50
  views: Any
51
+ set_atom_rs_label: Any
51
52
 
52
53
  def __init__(self, *args: Any, **kwargs: Any) -> None:
53
54
  """Initialize template placement mixin."""
@@ -696,6 +697,7 @@ class TemplateMixin:
696
697
  atom_item.symbol == symbol
697
698
  and atom_item.charge == charge
698
699
  and atom_item.radical == radical
700
+ and atom_item.rs_label == atom_data.get("rs")
699
701
  ):
700
702
  return
701
703
 
@@ -704,6 +706,7 @@ class TemplateMixin:
704
706
  atom_item.symbol = symbol
705
707
  atom_item.charge = charge
706
708
  atom_item.radical = radical
709
+ self.set_atom_rs_label(atom_item, atom_data.get("rs"))
707
710
 
708
711
  record = self.data.atoms.get(atom_item.atom_id)
709
712
  if record is not None:
@@ -746,6 +749,7 @@ class TemplateMixin:
746
749
  radical = atom_data.get("radical", 0)
747
750
 
748
751
  atom_id = self.create_atom(symbol, pos, charge, radical)
752
+ self.set_atom_rs_label(self.atom_items[atom_id], atom_data.get("rs"))
749
753
  atom_id_map[atom_data["id"]] = atom_id
750
754
 
751
755
  # Create bonds (bonds_info is always id-based)
@@ -20,6 +20,7 @@ from PySide6.QtWidgets import QGraphicsItem, QGraphicsScene
20
20
 
21
21
  from .atom_item import AtomItem
22
22
  from .bond_item import BondItem
23
+ from ..core.molecular_data import rs_entry
23
24
 
24
25
 
25
26
  class PreviewScene(QGraphicsScene):
@@ -96,6 +97,7 @@ def build_preview_items(
96
97
  radical=int(atom_data.get("radical", 0) or 0),
97
98
  )
98
99
  item.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable, False)
100
+ item.rs_label = rs_entry(atom_data).get("rs")
99
101
  item.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable, False)
100
102
  item.setAcceptHoverEvents(False)
101
103
  atom_items.append(item)
@@ -235,6 +235,7 @@ class TemplatePreviewItem(QGraphicsItem):
235
235
  atom.get("symbol", "C"),
236
236
  atom.get("charge", 0),
237
237
  atom.get("radical", 0),
238
+ atom.get("rs"),
238
239
  )
239
240
  for atom in preview_atoms
240
241
  ),
@@ -224,7 +224,9 @@ class UIManager(QObject):
224
224
  Handle application close logic.
225
225
  Returns True if close should proceed, False if it should be cancelled.
226
226
  """
227
- if not self.host.edit_3d_manager.prepare_optimization_shutdown():
227
+ calculations_ready = self.host.compute_manager.prepare_calculation_shutdown()
228
+ optimizations_ready = self.host.edit_3d_manager.prepare_optimization_shutdown()
229
+ if not calculations_ready or not optimizations_ready:
228
230
  return False
229
231
  # 1. Persist settings
230
232
  try:
@@ -271,17 +273,6 @@ class UIManager(QObject):
271
273
  # Safe defensive fallback catching RuntimeError, TypeError
272
274
  logging.debug("Suppressed non-critical error", exc_info=True)
273
275
 
274
- # Stop calculation threads
275
- active_threads = list(
276
- getattr(self.host.compute_manager, "_active_calc_threads", []) or []
277
- )
278
- for thr in active_threads:
279
- try:
280
- thr.quit()
281
- thr.wait(200)
282
- except (RuntimeError, TypeError):
283
- # Safe defensive fallback catching RuntimeError, TypeError
284
- logging.debug("Suppressed non-critical error", exc_info=True)
285
276
  except (AttributeError, RuntimeError, TypeError, ValueError):
286
277
  # Safe defensive fallback catching AttributeError, RuntimeError, TypeError, ValueError
287
278
  logging.debug("Suppressed non-critical error", exc_info=True)
@@ -36,6 +36,7 @@ from .preview_molecule import (
36
36
  from .template_preview_view import TemplatePreviewView
37
37
 
38
38
  from ..utils.constants import VERSION
39
+ from ..core.molecular_data import rs_entry
39
40
  import json
40
41
  import logging
41
42
  import os
@@ -402,6 +403,7 @@ class UserTemplateDialog(QDialog):
402
403
  "charge": atom.get("charge", 0),
403
404
  "radical": atom.get("radical", 0),
404
405
  "pos": QPointF(atom["x"], atom["y"]),
406
+ **rs_entry(atom),
405
407
  }
406
408
  for atom in atoms
407
409
  ]
@@ -545,6 +547,7 @@ class UserTemplateDialog(QDialog):
545
547
  "y": pos[1],
546
548
  "charge": atom_info.get("charge", 0),
547
549
  "radical": atom_info.get("radical", 0),
550
+ **rs_entry(atom_info),
548
551
  }
549
552
  )
550
553
 
@@ -10,6 +10,10 @@ Repo: https://github.com/HiroYokoyama/python_molecular_editor
10
10
  DOI: 10.5281/zenodo.17268532
11
11
  """
12
12
 
13
+ import copy
14
+ import math
15
+ from typing import Any
16
+
13
17
  from .label_style import DEFAULT_LABEL_SETTINGS
14
18
 
15
19
  DEFAULT_SETTINGS = {
@@ -96,3 +100,47 @@ DEFAULT_SETTINGS = {
96
100
  "cleanup_straighten_bonds_2d": False,
97
101
  "cleanup_avoid_clashes_2d": False,
98
102
  }
103
+
104
+
105
+ def normalize_settings(loaded: dict[str, Any]) -> dict[str, Any]:
106
+ """Keep plugin settings while replacing unsafe built-in values with defaults."""
107
+ result = copy.deepcopy(DEFAULT_SETTINGS)
108
+ result.update(loaded)
109
+ for key, default in DEFAULT_SETTINGS.items():
110
+ value = result[key]
111
+ valid = True
112
+ if isinstance(default, bool):
113
+ valid = isinstance(value, bool)
114
+ elif isinstance(default, (int, float)):
115
+ try:
116
+ number = float(value)
117
+ valid = (
118
+ isinstance(value, (int, float))
119
+ and not isinstance(value, bool)
120
+ and math.isfinite(number)
121
+ and 0 <= number <= (2**31 - 1) / 1000
122
+ )
123
+ if isinstance(default, int):
124
+ valid = valid and number.is_integer()
125
+ if key.endswith("_resolution") or key in (
126
+ "bond_dash_count_2d",
127
+ "atom_font_size_2d",
128
+ ):
129
+ valid = valid and number >= 1
130
+ if valid:
131
+ result[key] = int(number) if isinstance(default, int) else number
132
+ except (ValueError, TypeError, OverflowError):
133
+ valid = False
134
+ elif isinstance(default, str):
135
+ valid = isinstance(value, str) and bool(value.strip())
136
+ elif isinstance(default, dict):
137
+ valid = isinstance(value, dict)
138
+ if valid:
139
+ result[key] = {
140
+ symbol: color
141
+ for symbol, color in value.items()
142
+ if isinstance(symbol, str) and isinstance(color, str)
143
+ }
144
+ if not valid:
145
+ result[key] = copy.deepcopy(default)
146
+ return result
@@ -13,6 +13,7 @@ DOI: 10.5281/zenodo.17268532
13
13
  from __future__ import annotations
14
14
 
15
15
  import re
16
+ import math
16
17
  from collections.abc import Mapping
17
18
  from typing import Any, Dict, Tuple
18
19
 
@@ -79,7 +80,7 @@ def _get(settings: Any, key: str) -> Any:
79
80
  def _color(settings: Any, key: str) -> str:
80
81
  """A stored color if it is a valid #RRGGBB, else the default."""
81
82
  value = _get(settings, key)
82
- if isinstance(value, str) and _HEX_COLOR.match(value):
83
+ if isinstance(value, str) and _HEX_COLOR.fullmatch(value):
83
84
  return value
84
85
  return str(DEFAULT_LABEL_SETTINGS[key])
85
86
 
@@ -88,7 +89,12 @@ def _number(settings: Any, key: str, low: float, high: float) -> float:
88
89
  """A stored number clamped to [low, high], else the default."""
89
90
  value = _get(settings, key)
90
91
  if isinstance(value, (int, float)) and not isinstance(value, bool):
91
- return min(max(float(value), low), high)
92
+ try:
93
+ number = float(value)
94
+ except OverflowError:
95
+ return float(DEFAULT_LABEL_SETTINGS[key])
96
+ if math.isfinite(number):
97
+ return min(max(number, low), high)
92
98
  return float(DEFAULT_LABEL_SETTINGS[key])
93
99
 
94
100
 
@@ -101,7 +107,7 @@ def _flag(settings: Any, key: str) -> bool:
101
107
  def _family(settings: Any) -> str:
102
108
  """A stored VTK font family, else the default."""
103
109
  value = _get(settings, FONT_FAMILY_KEY)
104
- if value in {family for family, _ in FONT_FAMILIES}:
110
+ if isinstance(value, str) and value in {family for family, _ in FONT_FAMILIES}:
105
111
  return str(value)
106
112
  return str(DEFAULT_LABEL_SETTINGS[FONT_FAMILY_KEY])
107
113
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes