chython 2.19__cp313-cp313-win_amd64.whl

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 (175) hide show
  1. chython/__init__.py +35 -0
  2. chython/_functions.py +95 -0
  3. chython/_java.py +57 -0
  4. chython/algorithms/__init__.py +18 -0
  5. chython/algorithms/_isomorphism.cp313-win_amd64.pyd +0 -0
  6. chython/algorithms/_isomorphism.pyx +189 -0
  7. chython/algorithms/_rings.cp313-win_amd64.pyd +0 -0
  8. chython/algorithms/_rings.pyx +478 -0
  9. chython/algorithms/aromatics/__init__.py +27 -0
  10. chython/algorithms/aromatics/_rules.py +124 -0
  11. chython/algorithms/aromatics/kekule.py +532 -0
  12. chython/algorithms/aromatics/test/__init__.py +18 -0
  13. chython/algorithms/aromatics/test/test_kekule.py +160 -0
  14. chython/algorithms/aromatics/test/test_thiele.py +263 -0
  15. chython/algorithms/aromatics/thiele.py +222 -0
  16. chython/algorithms/calculate2d/__init__.py +23 -0
  17. chython/algorithms/calculate2d/clean2d.js +1 -0
  18. chython/algorithms/calculate2d/molecule.py +191 -0
  19. chython/algorithms/calculate2d/reaction.py +75 -0
  20. chython/algorithms/conformers.py +106 -0
  21. chython/algorithms/depict.py +596 -0
  22. chython/algorithms/fingerprints/__init__.py +43 -0
  23. chython/algorithms/fingerprints/linear.py +202 -0
  24. chython/algorithms/fingerprints/morgan.py +132 -0
  25. chython/algorithms/fingerprints/test/__init__.py +18 -0
  26. chython/algorithms/fingerprints/test/test_linear.py +127 -0
  27. chython/algorithms/fingerprints/test/test_morgan.py +79 -0
  28. chython/algorithms/groups/__init__.py +212 -0
  29. chython/algorithms/groups/_functional.py +290 -0
  30. chython/algorithms/groups/_oxidations.py +70 -0
  31. chython/algorithms/groups/_protective.py +964 -0
  32. chython/algorithms/groups/_reactions.py +809 -0
  33. chython/algorithms/groups/_reductions.py +88 -0
  34. chython/algorithms/groups/_transformations.py +278 -0
  35. chython/algorithms/groups/test/__init__.py +18 -0
  36. chython/algorithms/groups/test/test_deprotection.py +108 -0
  37. chython/algorithms/groups/test/test_reactions.py +377 -0
  38. chython/algorithms/isomorphism.py +664 -0
  39. chython/algorithms/mapping/__init__.py +188 -0
  40. chython/algorithms/mapping/_groups.py +94 -0
  41. chython/algorithms/mapping/_reactions.py +72 -0
  42. chython/algorithms/mapping/attention.py +266 -0
  43. chython/algorithms/mapping/reconstruct.py +346 -0
  44. chython/algorithms/mcs.py +201 -0
  45. chython/algorithms/morgan.py +77 -0
  46. chython/algorithms/rings.py +178 -0
  47. chython/algorithms/smiles.py +597 -0
  48. chython/algorithms/standardize/__init__.py +30 -0
  49. chython/algorithms/standardize/_groups.py +958 -0
  50. chython/algorithms/standardize/_isomers.py +330 -0
  51. chython/algorithms/standardize/_metal_organics.py +189 -0
  52. chython/algorithms/standardize/_reagents.py +44 -0
  53. chython/algorithms/standardize/_salts.py +68 -0
  54. chython/algorithms/standardize/molecule.py +475 -0
  55. chython/algorithms/standardize/reaction.py +384 -0
  56. chython/algorithms/standardize/resonance.py +191 -0
  57. chython/algorithms/standardize/salts.py +128 -0
  58. chython/algorithms/standardize/saturation.py +373 -0
  59. chython/algorithms/standardize/test/__init__.py +18 -0
  60. chython/algorithms/standardize/test/test_canonicalize.py +66 -0
  61. chython/algorithms/standardize/test/test_groups.py +146 -0
  62. chython/algorithms/standardize/test/test_isomers.py +198 -0
  63. chython/algorithms/stereo.py +1215 -0
  64. chython/algorithms/tautomers/__init__.py +167 -0
  65. chython/algorithms/tautomers/_acid.py +59 -0
  66. chython/algorithms/tautomers/_base.py +116 -0
  67. chython/algorithms/tautomers/_keto_enol.py +107 -0
  68. chython/algorithms/tautomers/acid_base.py +208 -0
  69. chython/algorithms/tautomers/heteroarenes.py +103 -0
  70. chython/algorithms/tautomers/keto_enol.py +143 -0
  71. chython/algorithms/tautomers/test/__init__.py +18 -0
  72. chython/algorithms/tautomers/test/test_tautomers.py +80 -0
  73. chython/algorithms/test/__init__.py +18 -0
  74. chython/algorithms/test/test_isomorphism.py +58 -0
  75. chython/algorithms/test/test_smiles.py +76 -0
  76. chython/algorithms/x3dom.py +350 -0
  77. chython/containers/__init__.py +48 -0
  78. chython/containers/_pack_v2.cp313-win_amd64.pyd +0 -0
  79. chython/containers/_pack_v2.pyx +271 -0
  80. chython/containers/_unpack_v0v2.cp313-win_amd64.pyd +0 -0
  81. chython/containers/_unpack_v0v2.pyx +302 -0
  82. chython/containers/bonds.py +255 -0
  83. chython/containers/cgr.py +115 -0
  84. chython/containers/chimera.py +63 -0
  85. chython/containers/graph.py +199 -0
  86. chython/containers/molecule.py +817 -0
  87. chython/containers/query.py +71 -0
  88. chython/containers/rdkit.py +183 -0
  89. chython/containers/reaction.py +324 -0
  90. chython/containers/test/__init__.py +18 -0
  91. chython/containers/test/test_molecule.py +117 -0
  92. chython/containers/test/test_pack.py +60 -0
  93. chython/containers/test/test_rdkit.py +70 -0
  94. chython/containers/test/test_reaction.py +40 -0
  95. chython/exceptions.py +114 -0
  96. chython/files/MRVrw.py +532 -0
  97. chython/files/PDBrw.py +255 -0
  98. chython/files/RDFrw.py +309 -0
  99. chython/files/SDFrw.py +221 -0
  100. chython/files/__init__.py +30 -0
  101. chython/files/_convert.py +207 -0
  102. chython/files/_mapping.py +117 -0
  103. chython/files/_xyz.cp313-win_amd64.pyd +0 -0
  104. chython/files/_xyz.pyx +74 -0
  105. chython/files/daylight/__init__.py +24 -0
  106. chython/files/daylight/parser.py +157 -0
  107. chython/files/daylight/smarts.py +107 -0
  108. chython/files/daylight/smiles.py +260 -0
  109. chython/files/daylight/test/__init__.py +18 -0
  110. chython/files/daylight/test/test_daylight_smarts.py +289 -0
  111. chython/files/daylight/test/test_daylight_smiles.py +101 -0
  112. chython/files/daylight/test/test_parser.py +118 -0
  113. chython/files/daylight/test/test_tokenize.py +77 -0
  114. chython/files/daylight/tokenize.py +376 -0
  115. chython/files/libinchi/__init__.py +22 -0
  116. chython/files/libinchi/libinchi.dll +0 -0
  117. chython/files/libinchi/wrapper.py +554 -0
  118. chython/files/mdl/__init__.py +25 -0
  119. chython/files/mdl/emol.py +251 -0
  120. chython/files/mdl/erxn.py +67 -0
  121. chython/files/mdl/mol.py +186 -0
  122. chython/files/mdl/read.py +223 -0
  123. chython/files/mdl/rxn.py +67 -0
  124. chython/files/mdl/stereo.py +63 -0
  125. chython/files/mdl/write.py +201 -0
  126. chython/files/opsin.py +35 -0
  127. chython/files/test/__init__.py +0 -0
  128. chython/files/test/test_inchi.py +27 -0
  129. chython/files/test/test_mrv.py +47 -0
  130. chython/files/test/test_sdf_rdf.py +64 -0
  131. chython/files/test/test_xyz.py +20 -0
  132. chython/files/xyz.py +78 -0
  133. chython/periodictable/__init__.py +69 -0
  134. chython/periodictable/base/__init__.py +25 -0
  135. chython/periodictable/base/dynamic.py +149 -0
  136. chython/periodictable/base/element.py +451 -0
  137. chython/periodictable/base/groups.py +90 -0
  138. chython/periodictable/base/periods.py +46 -0
  139. chython/periodictable/base/query.py +473 -0
  140. chython/periodictable/base/vector.py +119 -0
  141. chython/periodictable/groupI.py +252 -0
  142. chython/periodictable/groupII.py +223 -0
  143. chython/periodictable/groupIII.py +1154 -0
  144. chython/periodictable/groupIV.py +210 -0
  145. chython/periodictable/groupIX.py +196 -0
  146. chython/periodictable/groupV.py +192 -0
  147. chython/periodictable/groupVI.py +183 -0
  148. chython/periodictable/groupVII.py +162 -0
  149. chython/periodictable/groupVIII.py +161 -0
  150. chython/periodictable/groupX.py +166 -0
  151. chython/periodictable/groupXI.py +164 -0
  152. chython/periodictable/groupXII.py +158 -0
  153. chython/periodictable/groupXIII.py +239 -0
  154. chython/periodictable/groupXIV.py +262 -0
  155. chython/periodictable/groupXV.py +276 -0
  156. chython/periodictable/groupXVI.py +457 -0
  157. chython/periodictable/groupXVII.py +324 -0
  158. chython/periodictable/groupXVIII.py +260 -0
  159. chython/periodictable/test/__init__.py +0 -0
  160. chython/periodictable/test/test_element.py +100 -0
  161. chython/reactor/__init__.py +24 -0
  162. chython/reactor/base.py +222 -0
  163. chython/reactor/reactor.py +177 -0
  164. chython/reactor/test/__init__.py +18 -0
  165. chython/reactor/test/test_reactor.py +40 -0
  166. chython/reactor/test/test_transformer.py +78 -0
  167. chython/reactor/transformer.py +72 -0
  168. chython/utils/__init__.py +50 -0
  169. chython/utils/free_wilson.py +114 -0
  170. chython/utils/grid.py +130 -0
  171. chython/utils/retro.py +139 -0
  172. chython-2.19.dist-info/METADATA +105 -0
  173. chython-2.19.dist-info/RECORD +175 -0
  174. chython-2.19.dist-info/WHEEL +4 -0
  175. chython-2.19.dist-info/licenses/LICENSE +165 -0
@@ -0,0 +1,223 @@
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+ # Copyright 2021-2026 Ramil Nugmanov <nougmanoff@protonmail.com>
4
+ # This file is part of chython.
5
+ #
6
+ # chython is free software; you can redistribute it and/or modify
7
+ # it under the terms of the GNU Lesser General Public License as published by
8
+ # the Free Software Foundation; either version 3 of the License, or
9
+ # (at your option) any later version.
10
+ #
11
+ # This program is distributed in the hope that it will be useful,
12
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
+ # GNU Lesser General Public License for more details.
15
+ #
16
+ # You should have received a copy of the GNU Lesser General Public License
17
+ # along with this program; if not, see <https://www.gnu.org/licenses/>.
18
+ #
19
+ from abc import ABCMeta, abstractmethod
20
+ from base64 import urlsafe_b64encode
21
+ from itertools import islice
22
+ from os.path import abspath, join
23
+ from pathlib import Path
24
+ from pickle import load, UnpicklingError
25
+ from sys import platform
26
+ from tempfile import gettempdir
27
+ from typing import Union, Iterator, List, Dict, Optional
28
+ from ...containers import ReactionContainer, MoleculeContainer
29
+
30
+
31
+ class MDLReadMeta(ABCMeta):
32
+ def __call__(cls, *args, **kwargs):
33
+ if kwargs.get('indexable'):
34
+ _cls = type(cls.__name__, (cls,), {'__len__': lambda x: len(x._shifts), '__module__': cls.__module__})
35
+ obj = object.__new__(_cls) # noqa
36
+ else:
37
+ obj = object.__new__(cls) # noqa
38
+ obj.__init__(*args, **kwargs)
39
+ return obj
40
+
41
+
42
+ class MDLRead(metaclass=MDLReadMeta):
43
+ def __init__(self, file, buffer_size=1000, indexable=False, ignore=True, remap=False, ignore_bad_isotopes=False,
44
+ ignore_stereo=False, calc_cis_trans=False):
45
+ if isinstance(file, str):
46
+ self._file = open(file)
47
+ self._is_buffer = False
48
+ elif isinstance(file, Path):
49
+ self._file = file.open()
50
+ self._is_buffer = False
51
+ elif hasattr(file, '__iter__') and hasattr(file, 'read'):
52
+ self._file = file
53
+ self._is_buffer = True
54
+ else:
55
+ raise TypeError('invalid file. file-like object or path to file expected')
56
+ self._shifts = None
57
+ self._tell = 0
58
+ self._buffer_size = buffer_size
59
+ self._buffer = None
60
+ self._ignore = ignore
61
+ self._remap = remap
62
+ self._ignore_bad_isotopes = ignore_bad_isotopes
63
+ self._ignore_stereo = ignore_stereo
64
+ self._calc_cis_trans = calc_cis_trans
65
+ if indexable:
66
+ self._load_cache()
67
+
68
+ def read(self, amount: Optional[int] = None) -> List[Union[ReactionContainer, MoleculeContainer]]:
69
+ """
70
+ Parse whole file
71
+
72
+ :param amount: number of records to read
73
+ """
74
+ if amount:
75
+ return list(islice(iter(self), amount))
76
+ return list(iter(self))
77
+
78
+ @abstractmethod
79
+ def read_structure(self, *, current: bool = True):
80
+ """
81
+ Read Reaction or Molecule container.
82
+
83
+ :param current: return current structure if already parsed, otherwise read next
84
+ """
85
+
86
+ @abstractmethod
87
+ def read_metadata(self, *, current: bool = True) -> Dict[str, str]:
88
+ """
89
+ Read metadata block
90
+ """
91
+
92
+ def close(self, force: bool = False):
93
+ """
94
+ Close opened file
95
+
96
+ :param force: force closing of externally opened file or buffer
97
+ """
98
+ if not self._is_buffer or force:
99
+ self._file.close()
100
+
101
+ def tell(self):
102
+ """
103
+ Number of records processed from the original file
104
+ """
105
+ return self._tell
106
+
107
+ def seek(self, offset):
108
+ """
109
+ Shift to a given record number
110
+ """
111
+ if self._shifts:
112
+ if 0 <= offset < len(self._shifts):
113
+ self._tell = offset
114
+ self._buffer = None
115
+ self._file.seek(self._shifts[offset])
116
+ else:
117
+ raise IndexError('invalid offset')
118
+ else:
119
+ raise NotImplementedError('Indexable supported in unix-like o.s. and for files stored on disk')
120
+
121
+ @abstractmethod
122
+ def reset_index(self):
123
+ """
124
+ Create (rewrite) indexation table. Implemented only for object that
125
+ is a real file (the path to the file is specified) because the external grep utility is used.
126
+ """
127
+
128
+ def read_block(self, *, current: bool = True) -> str:
129
+ """
130
+ Read full record block with metadata
131
+ """
132
+ return ''.join(self._read_block(current=current))
133
+
134
+ @abstractmethod
135
+ def _read_block(self, *, current: bool = True) -> List[str]:
136
+ """
137
+ Read full record block with metadata
138
+ """
139
+
140
+ def _load_cache(self):
141
+ """
142
+ Load existing cache or create new. Working only for UNIX-like systems and local files (not buffers).
143
+ """
144
+ if platform == 'win32' or self._is_buffer:
145
+ return
146
+ try:
147
+ with open(self._cache_path, 'rb') as f:
148
+ self._shifts = load(f)
149
+ except FileNotFoundError: # cache not found
150
+ self.reset_index()
151
+ except IsADirectoryError as e:
152
+ raise IsADirectoryError(f'Please delete {self._cache_path} directory') from e
153
+ except (UnpicklingError, EOFError) as e: # invalid file. ask user to check it.
154
+ raise UnpicklingError(f'Invalid cache file {self._cache_path}. Please delete it') from e
155
+
156
+ @property
157
+ def _cache_path(self):
158
+ return abspath(join(gettempdir(), 'chython_' + urlsafe_b64encode(abspath(self._file.name).encode()).decode()))
159
+
160
+ def __enter__(self):
161
+ return self
162
+
163
+ def __exit__(self, _type, value, traceback):
164
+ self.close()
165
+
166
+ def __iter__(self) -> Iterator[Union[ReactionContainer, MoleculeContainer]]:
167
+ while True:
168
+ try:
169
+ yield self.read_structure(current=False)
170
+ except ValueError:
171
+ pass
172
+ except EOFError:
173
+ return
174
+
175
+ def __next__(self) -> Union[ReactionContainer, MoleculeContainer]:
176
+ return next(iter(self))
177
+
178
+ def __getitem__(self, item) -> Union[ReactionContainer, MoleculeContainer,
179
+ List[Union[ReactionContainer, MoleculeContainer]]]:
180
+ """
181
+ Getting the item by index from the original file,
182
+ For slices records with errors skipped.
183
+ """
184
+ if self._shifts:
185
+ _len = len(self._shifts)
186
+ if isinstance(item, int):
187
+ if item >= _len or item < -_len:
188
+ raise IndexError('List index out of range')
189
+ if item < 0:
190
+ item += _len
191
+ self.seek(item)
192
+ return self.read_structure()
193
+ elif isinstance(item, slice):
194
+ start, stop, step = item.indices(_len)
195
+ if start == stop:
196
+ return []
197
+ if step == 1:
198
+ self.seek(start)
199
+ records = []
200
+ for _ in range(start, stop):
201
+ try:
202
+ records.append(self.read_structure(current=False))
203
+ except EOFError:
204
+ break
205
+ except ValueError:
206
+ pass
207
+ else:
208
+ records = []
209
+ for index in range(start, stop, step):
210
+ self.seek(index)
211
+ try:
212
+ records.append(self.read_structure())
213
+ except EOFError:
214
+ break
215
+ except ValueError:
216
+ pass
217
+ return records
218
+ else:
219
+ raise TypeError('Indices must be integers or slices')
220
+ raise NotImplementedError('Indexable supported in unix-like o.s. and for files stored on disk')
221
+
222
+
223
+ __all__ = ['MDLRead']
@@ -0,0 +1,67 @@
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+ # Copyright 2020-2026 Ramil Nugmanov <nougmanoff@protonmail.com>
4
+ # This file is part of chython.
5
+ #
6
+ # chython is free software; you can redistribute it and/or modify
7
+ # it under the terms of the GNU Lesser General Public License as published by
8
+ # the Free Software Foundation; either version 3 of the License, or
9
+ # (at your option) any later version.
10
+ #
11
+ # This program is distributed in the hope that it will be useful,
12
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
+ # GNU Lesser General Public License for more details.
15
+ #
16
+ # You should have received a copy of the GNU Lesser General Public License
17
+ # along with this program; if not, see <https://www.gnu.org/licenses/>.
18
+ #
19
+ from .mol import parse_mol_v2000
20
+ from ...exceptions import EmptyMolecule, EmptyReaction, InvalidV2000
21
+
22
+
23
+ def parse_rxn_v2000(data, *, ignore=True):
24
+ line = data[4]
25
+ reactants_count = int(line[:3])
26
+ products_count = int(line[3:6]) + reactants_count
27
+ reagents_count = int(line[6:].rstrip() or 0) + products_count
28
+
29
+ if not reagents_count:
30
+ raise EmptyReaction
31
+
32
+ title = data[1].strip() or None
33
+ log = []
34
+ molecules = []
35
+
36
+ start = -1
37
+ for n in range(0, reagents_count):
38
+ try:
39
+ start = next(n for n, x in enumerate(data[start + 6:], start + 7) if x.startswith('$MOL'))
40
+ except StopIteration:
41
+ raise InvalidV2000
42
+
43
+ try:
44
+ molecules.append(parse_mol_v2000(data[start:]))
45
+ except ValueError as e:
46
+ if isinstance(e, EmptyMolecule):
47
+ log.append(f'ignored empty molecule {n}')
48
+ elif ignore:
49
+ log.append(f'ignored molecule {n} with {e}')
50
+ else:
51
+ raise
52
+
53
+ if (lm := len(molecules)) < reactants_count:
54
+ reactants_count -= 1
55
+ products_count -= 1
56
+ reagents_count -= 1
57
+ elif lm < products_count:
58
+ products_count -= 1
59
+ reagents_count -= 1
60
+ else:
61
+ reagents_count -= 1
62
+
63
+ return {'reactants': molecules[:reactants_count], 'products': molecules[reactants_count:products_count],
64
+ 'reagents': molecules[products_count:], 'title': title, 'log': log}
65
+
66
+
67
+ __all__ = ['parse_rxn_v2000']
@@ -0,0 +1,63 @@
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+ # Copyright 2020-2026 Ramil Nugmanov <nougmanoff@protonmail.com>
4
+ # This file is part of chython.
5
+ #
6
+ # chython is free software; you can redistribute it and/or modify
7
+ # it under the terms of the GNU Lesser General Public License as published by
8
+ # the Free Software Foundation; either version 3 of the License, or
9
+ # (at your option) any later version.
10
+ #
11
+ # This program is distributed in the hope that it will be useful,
12
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
+ # GNU Lesser General Public License for more details.
15
+ #
16
+ # You should have received a copy of the GNU Lesser General Public License
17
+ # along with this program; if not, see <https://www.gnu.org/licenses/>.
18
+ #
19
+ from ...exceptions import NotChiral, IsChiral, ValenceError
20
+
21
+
22
+ def postprocess_molecule(molecule, data, *, ignore_stereo=False, calc_cis_trans=False):
23
+ if ignore_stereo:
24
+ return
25
+ mapping = data['mapping']
26
+ log = []
27
+
28
+ if calc_cis_trans:
29
+ molecule.calculate_cis_trans_from_2d(clean_cache=False)
30
+
31
+ stereo = [(mapping[n], mapping[m], s) for n, m, s in data['stereo']]
32
+ while stereo:
33
+ fail_stereo = []
34
+ old_stereo = len(stereo)
35
+ for n, m, s in stereo:
36
+ try:
37
+ molecule.add_wedge(n, m, s, clean_cache=False)
38
+ except NotChiral:
39
+ fail_stereo.append((n, m, s))
40
+ except IsChiral:
41
+ pass
42
+ except ValenceError:
43
+ log.append('structure has errors, stereo data skipped')
44
+ molecule.flush_cache()
45
+ break
46
+ else:
47
+ stereo = fail_stereo
48
+ if len(stereo) == old_stereo:
49
+ break
50
+ molecule.flush_stereo_cache()
51
+ if calc_cis_trans:
52
+ molecule.calculate_cis_trans_from_2d(clean_cache=False)
53
+ continue
54
+ break
55
+
56
+ if log:
57
+ if 'chython_parsing_log' not in molecule.meta:
58
+ molecule.meta['chython_parsing_log'] = log
59
+ else:
60
+ molecule.meta['chython_parsing_log'].extend(log)
61
+
62
+
63
+ __all__ = ['postprocess_molecule']
@@ -0,0 +1,201 @@
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+ # Copyright 2021-2026 Ramil Nugmanov <nougmanoff@protonmail.com>
4
+ # This file is part of chython.
5
+ #
6
+ # chython is free software; you can redistribute it and/or modify
7
+ # it under the terms of the GNU Lesser General Public License as published by
8
+ # the Free Software Foundation; either version 3 of the License, or
9
+ # (at your option) any later version.
10
+ #
11
+ # This program is distributed in the hope that it will be useful,
12
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
+ # GNU Lesser General Public License for more details.
15
+ #
16
+ # You should have received a copy of the GNU Lesser General Public License
17
+ # along with this program; if not, see <https://www.gnu.org/licenses/>.
18
+ #
19
+ from collections import defaultdict
20
+ from pathlib import Path
21
+ from ...containers import MoleculeContainer
22
+
23
+
24
+ charge_map = {-4: ' 0', -3: ' 7', -2: ' 6', -1: ' 5', 0: ' 0', 1: ' 3', 2: ' 2', 3: ' 1', 4: ' 0'}
25
+
26
+
27
+ class IO:
28
+ def __init__(self, file, *, mapping: bool = True, append: bool = False):
29
+ """
30
+ :param mapping: write atom mapping.
31
+ :param append: open file path in append mode.
32
+ """
33
+ self._mapping = mapping
34
+
35
+ if isinstance(file, str):
36
+ self._file = open(file, 'a' if append else 'w')
37
+ self._is_buffer = False
38
+ elif isinstance(file, Path):
39
+ self._file = file.open('a' if append else 'w')
40
+ self._is_buffer = False
41
+ elif hasattr(file, 'write'):
42
+ self._file = file
43
+ self._is_buffer = True
44
+ else:
45
+ raise TypeError('invalid file. file-like object or path to file expected')
46
+
47
+ def close(self, force=False):
48
+ """
49
+ Close opened file
50
+
51
+ :param force: force closing of externally opened file or buffer
52
+ """
53
+ self.write = self.__write_closed
54
+
55
+ if not self._is_buffer or force:
56
+ self._file.close()
57
+
58
+ def __enter__(self):
59
+ return self
60
+
61
+ def __exit__(self, _type, value, traceback):
62
+ self.close()
63
+
64
+ @staticmethod
65
+ def __write_closed(_):
66
+ raise ValueError('I/O operation on closed writer')
67
+
68
+
69
+ class EMOLWrite(IO):
70
+ def __init__(self, file, *, mapping: bool = True, append: bool = False, absolute: bool = False):
71
+ """
72
+ :param mapping: write atom mapping.
73
+ :param append: open file path in append mode.
74
+ :param absolute: explicitly write MDLV30/STEABS collection for stereocenters without extended stereo groups.
75
+ By default chython treats any set chirality flag as absolute if not in an AND/OR group,
76
+ but some tools require the ABS collection to be explicitly present in the file.
77
+ """
78
+ self._absolute = absolute
79
+ super().__init__(file, mapping=mapping, append=append)
80
+
81
+ def _write_molecule(self, g, write3d=None):
82
+ if not isinstance(g, MoleculeContainer):
83
+ raise TypeError('MoleculeContainer expected')
84
+
85
+ if write3d is not None:
86
+ xyz = g._conformers[write3d]
87
+ else:
88
+ z = 0
89
+
90
+ bonds = g._bonds
91
+
92
+ file = self._file
93
+ file.write(f'M V30 BEGIN CTAB\nM V30 COUNTS {g.atoms_count} {g.bonds_count} 0 0 0\nM V30 BEGIN ATOM\n')
94
+
95
+ for n, (m, a) in enumerate(g.atoms(), start=1):
96
+ if write3d is not None:
97
+ x, y, z = xyz[m]
98
+ z = f'{z:.4f}'
99
+ else:
100
+ x, y = a.x, a.y
101
+
102
+ c = f' CHG={a.charge}' if a.charge else ''
103
+ r = ' RAD=2' if a.is_radical else ''
104
+ i = f' MASS={a.isotope}' if a.isotope else ''
105
+
106
+ if not self._mapping:
107
+ m = 0
108
+ file.write(f'M V30 {n} {a.atomic_symbol} {x:.4f} {y:.4f} {z} {m}{c}{r}{i}\n')
109
+
110
+ file.write('M V30 END ATOM\nM V30 BEGIN BOND\n')
111
+
112
+ mapping = {m: n for n, m in enumerate(g, start=1)}
113
+ wedge = defaultdict(set)
114
+ i = 0 # trick for empty wedge_map
115
+ for i, (n, m, s) in enumerate(g._wedge_map, start=1):
116
+ file.write(f'M V30 {i} {bonds[n][m].order} {mapping[n]} {mapping[m]} CFG={s == 1 and "1" or "3"}\n')
117
+ wedge[n].add(m)
118
+ wedge[m].add(n)
119
+
120
+ for n, m, b in g.bonds():
121
+ if m not in wedge[n]:
122
+ i += 1
123
+ file.write(f'M V30 {i} {b.order} {mapping[n]} {mapping[m]}\n')
124
+ file.write('M V30 END BOND\n')
125
+
126
+ # enhanced stereo collection
127
+ rac = defaultdict(list) # AND groups: positive extended_stereo
128
+ rel = defaultdict(list) # OR groups: negative extended_stereo
129
+ ast = [] # absolute stereo
130
+ for m, a in g.atoms():
131
+ if (es := a.extended_stereo) is not None:
132
+ if es > 0:
133
+ rac[es].append(mapping[m])
134
+ elif es < 0:
135
+ rel[-es].append(mapping[m])
136
+ elif a.stereo is not None:
137
+ ast.append(mapping[m])
138
+ if rac or rel or (self._absolute and ast):
139
+ file.write('M V30 BEGIN COLLECTION\n')
140
+ if self._absolute and ast:
141
+ file.write(f'M V30 MDLV30/STEABS ATOMS=({len(ast)} {" ".join(str(x) for x in ast)})\n')
142
+ for gid in sorted(rac):
143
+ al = rac[gid]
144
+ file.write(f'M V30 MDLV30/STERAC{gid} ATOMS=({len(al)} {" ".join(str(x) for x in al)})\n')
145
+ for gid in sorted(rel):
146
+ al = rel[gid]
147
+ file.write(f'M V30 MDLV30/STEREL{gid} ATOMS=({len(al)} {" ".join(str(x) for x in al)})\n')
148
+ file.write('M V30 END COLLECTION\n')
149
+
150
+ file.write('M V30 END CTAB\n')
151
+
152
+
153
+ class MOLWrite(IO):
154
+ def _write_molecule(self, g, write3d=None):
155
+ if not isinstance(g, MoleculeContainer):
156
+ raise TypeError('MoleculeContainer expected')
157
+ elif max(g) > 999:
158
+ raise ValueError('MOL file support only small molecules')
159
+
160
+ if write3d is not None:
161
+ xyz = g._conformers[write3d]
162
+ else:
163
+ z = 0.
164
+
165
+ bonds = g._bonds
166
+
167
+ file = self._file
168
+ file.write(f'{g.name}\n\n\n{g.atoms_count:3d}{g.bonds_count:3d} 0 0 0 0 999 V2000\n')
169
+
170
+ for n, (m, a) in enumerate(g.atoms(), start=1):
171
+ if write3d is not None:
172
+ x, y, z = xyz[m]
173
+ else:
174
+ x, y = a.x, a.y
175
+
176
+ c = charge_map[a.charge]
177
+ if not self._mapping:
178
+ m = 0
179
+ file.write(f'{x:10.4f}{y:10.4f}{z:10.4f} {a.atomic_symbol:3s} 0{c} 0 0 0 0 0 0 0{m:3d} 0 0\n')
180
+
181
+ atoms = {m: n for n, m in enumerate(g, start=1)}
182
+ wedge = defaultdict(set)
183
+ for n, m, s in g._wedge_map:
184
+ file.write(f'{atoms[n]:3d}{atoms[m]:3d} {bonds[n][m].order} {s == 1 and "1" or "6"} 0 0 0\n')
185
+ wedge[n].add(m)
186
+ wedge[m].add(n)
187
+ for n, m, b in g.bonds():
188
+ if m not in wedge[n]:
189
+ file.write(f'{atoms[n]:3d}{atoms[m]:3d} {b.order} 0 0 0 0\n')
190
+
191
+ for n, (_, a) in enumerate(g.atoms(), start=1):
192
+ if a.isotope:
193
+ file.write(f'M ISO 1 {n:3d} {a.isotope:3d}\n')
194
+ if a.is_radical:
195
+ file.write(f'M RAD 1 {n:3d} 2\n') # invalid for carbenes
196
+ if a.charge in (-4, 4):
197
+ file.write(f'M CHG 1 {n:3d} {a.charge:3d}\n')
198
+ file.write('M END\n')
199
+
200
+
201
+ __all__ = ['MOLWrite', 'EMOLWrite']
chython/files/opsin.py ADDED
@@ -0,0 +1,35 @@
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+ # Copyright 2025, 2026 Ramil Nugmanov <nougmanoff@protonmail.com>
4
+ # This file is part of chython.
5
+ #
6
+ # chython is free software; you can redistribute it and/or modify
7
+ # it under the terms of the GNU Lesser General Public License as published by
8
+ # the Free Software Foundation; either version 3 of the License, or
9
+ # (at your option) any later version.
10
+ #
11
+ # This program is distributed in the hope that it will be useful,
12
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
+ # GNU Lesser General Public License for more details.
15
+ #
16
+ # You should have received a copy of the GNU Lesser General Public License
17
+ # along with this program; if not, see <https://www.gnu.org/licenses/>.
18
+ #
19
+ from .daylight import smiles
20
+
21
+
22
+ def opsin(string):
23
+ """Parse IUPAC name into MoleculeContainer."""
24
+ from .._java import get_opsin
25
+
26
+ result = get_opsin().parseChemicalName(string)
27
+ if str(result.getStatus()) == 'FAILURE':
28
+ raise ValueError(f'Failed to convert `{string}`: {result.getMessage()}')
29
+ return smiles(str(result.getSmiles()))
30
+
31
+
32
+ iupac = opsin
33
+
34
+
35
+ __all__ = ['opsin', 'iupac']
File without changes
@@ -0,0 +1,27 @@
1
+ # -*- coding: utf-8 -*-
2
+ import pytest
3
+
4
+ try:
5
+ from chython import inchi
6
+ HAS_INCHI = True
7
+ except ImportError:
8
+ HAS_INCHI = False
9
+
10
+
11
+ @pytest.mark.skipif(not HAS_INCHI, reason='libinchi not available')
12
+ def test_inchi_parse_ethanol():
13
+ mol = inchi('InChI=1S/C2H6O/c1-2-3/h3H,2H2,1H3')
14
+ assert len(mol) == 3
15
+ assert 'O' in {a.atomic_symbol for _, a in mol.atoms()}
16
+
17
+
18
+ @pytest.mark.skipif(not HAS_INCHI, reason='libinchi not available')
19
+ def test_inchi_parse_benzene():
20
+ mol = inchi('InChI=1S/C6H6/c1-2-4-6-5-3-1/h1-6H')
21
+ assert len(mol) == 6
22
+
23
+
24
+ @pytest.mark.skipif(not HAS_INCHI, reason='libinchi not available')
25
+ def test_inchi_parse_charged():
26
+ mol = inchi('InChI=1S/Na.H2O/h;1H2/q+1;/p-1')
27
+ assert mol is not None
@@ -0,0 +1,47 @@
1
+ # -*- coding: utf-8 -*-
2
+ from io import BytesIO, StringIO
3
+ from chython import smiles
4
+ from chython.files import MRVRead, MRVWrite
5
+
6
+
7
+ def test_mrv_read_molecule():
8
+ with MRVRead('test/implicit.mrv') as f:
9
+ mol = next(f)
10
+ assert mol is not None
11
+ assert len(mol) > 0
12
+ assert 'N' in {a.atomic_symbol for _, a in mol.atoms()}
13
+
14
+
15
+ def test_mrv_read_all():
16
+ with MRVRead('test/implicit.mrv') as f:
17
+ mols = f.read()
18
+ assert len(mols) == 1
19
+
20
+
21
+ def test_mrv_write_read_roundtrip():
22
+ mol = smiles('c1ccccc1')
23
+ mol.clean2d()
24
+
25
+ buf = StringIO()
26
+ with MRVWrite(buf) as w:
27
+ w.write(mol)
28
+
29
+ xml_bytes = buf.getvalue().encode()
30
+ with MRVRead(BytesIO(xml_bytes)) as r:
31
+ mol2 = next(r)
32
+
33
+ assert str(mol) == str(mol2)
34
+
35
+
36
+ def test_mrv_write_reaction():
37
+ rxn = smiles('[CH3:1][OH:2]>>[CH3:1][NH2:3]')
38
+ for m in rxn.molecules():
39
+ m.clean2d()
40
+
41
+ buf = StringIO()
42
+ with MRVWrite(buf) as w:
43
+ w.write(rxn)
44
+
45
+ assert '<reaction>' in buf.getvalue()
46
+ assert 'reactantList' in buf.getvalue()
47
+ assert 'productList' in buf.getvalue()