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,532 @@
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, deque
20
+ from collections.abc import Iterator
21
+ from ._rules import rules
22
+ from ..._functions import lazy_product
23
+ from ...exceptions import InvalidAromaticRing
24
+
25
+
26
+ # atomic number constants
27
+ B = 5
28
+ C = 6
29
+ N = 7
30
+ O = 8
31
+ P = 15
32
+ S = 16
33
+ As = 33
34
+ Se = 34
35
+ Te = 52
36
+
37
+
38
+ class Kekule:
39
+ __slots__ = ()
40
+
41
+ def kekule(self, *, buffer_size=7, ignore_pyrrole_hydrogen=False) -> bool:
42
+ """
43
+ Convert structure to kekule form. Return True if found any aromatic ring. Set implicit hydrogen count and
44
+ hybridization marks on atoms.
45
+
46
+ Only one of possible double/single bonds positions will be set.
47
+ For enumerate bonds positions use `enumerate_kekule`.
48
+
49
+ :param buffer_size: number of attempts of pyridine form searching.
50
+ :param ignore_pyrrole_hydrogen: ignore hydrogen on pyrrole to fix invalid rings like Cn1cc[nH]c1.
51
+ """
52
+ fixed = self.__fix_rings() # fix bad aromatic rings
53
+ kekule = next(self.__kekule_full(buffer_size, ignore_pyrrole_hydrogen), None)
54
+ if kekule:
55
+ bonds = self._bonds
56
+ atoms = set()
57
+ for n, m, b in kekule:
58
+ bonds[n][m]._order = b
59
+ atoms.add(n)
60
+ atoms.add(m)
61
+ for n in atoms:
62
+ self.calc_implicit(n)
63
+ self.flush_cache(keep_sssr=True, keep_components=True, keep_special_connectivity=True)
64
+ self.calc_labels()
65
+ return True
66
+ return fixed
67
+
68
+ def enumerate_kekule(self, ignore_pyrrole_hydrogen=False) -> Iterator['MoleculeContainer']:
69
+ """
70
+ Enumerate all possible kekule forms of molecule.
71
+ """
72
+ self.__fix_rings() # fix bad aromatic rings
73
+ for form in self.__kekule_full(0, ignore_pyrrole_hydrogen):
74
+ copy = self.copy(keep_sssr=True, keep_components=True)
75
+ bonds = copy._bonds
76
+ atoms = set()
77
+ for n, m, b in form:
78
+ bonds[n][m]._order = b
79
+ atoms.add(n)
80
+ atoms.add(m)
81
+ for n in atoms:
82
+ copy.calc_implicit(n)
83
+ copy.calc_labels()
84
+ yield copy
85
+
86
+ def __fix_rings(self):
87
+ atoms = self._atoms
88
+ bonds = self._bonds
89
+ seen = set()
90
+ keep = True
91
+ for q, af, bf, mm in rules:
92
+ for mapping in q.get_mapping(self, automorphism_filter=False):
93
+ match = set(mapping.values())
94
+ if not mm and not match.isdisjoint(seen): # prevent double patching of atoms
95
+ continue
96
+ seen.update(match)
97
+
98
+ for n, c in af.items():
99
+ n = mapping[n]
100
+ atoms[n]._charge = c
101
+ for n, m, b in bf:
102
+ n = mapping[n]
103
+ m = mapping[m]
104
+ bond = bonds[n][m]
105
+ bond._order = b
106
+ bond._stereo = None # prevent potential errors with cis-trans-labelled double-bonds
107
+ if b == 8:
108
+ # flush sssr and components cache
109
+ keep = False
110
+ if seen:
111
+ self.flush_cache(keep_sssr=keep, keep_components=keep, keep_special_connectivity=keep)
112
+ self.calc_labels()
113
+ return True
114
+ return False
115
+
116
+ def __prepare_rings(self, ignore_pyrrole_hydrogen):
117
+ atoms = self._atoms
118
+ bonds = self._bonds
119
+
120
+ rings = defaultdict(list) # aromatic skeleton
121
+ pyrroles = set()
122
+
123
+ double_bonded = defaultdict(list)
124
+ triple_bonded = set()
125
+ for n, m_bond in bonds.items():
126
+ for m, bond in m_bond.items():
127
+ if bond == 4:
128
+ rings[n].append(m)
129
+ elif bond == 2:
130
+ double_bonded[n].append(m)
131
+ elif bond == 3:
132
+ triple_bonded.add(n)
133
+
134
+ if not rings:
135
+ return rings, pyrroles, set()
136
+ elif not triple_bonded.isdisjoint(rings):
137
+ raise InvalidAromaticRing('triple bonds connected to rings')
138
+
139
+ copy_rings = {n: ms.copy() for n, ms in rings.items()}
140
+ for r in self.sssr:
141
+ if set(r).issubset(rings):
142
+ n, *_, m = r
143
+ if n not in rings[m]: # fix invalid structures: c1ccc-cc1
144
+ # remove inner ring double bonds: c1ccc=cc1
145
+ if n in double_bonded and m in double_bonded and m in double_bonded[n]:
146
+ double_bonded[n].remove(m)
147
+ double_bonded[m].remove(n)
148
+ rings[m].append(n)
149
+ rings[n].append(m)
150
+ elif m in copy_rings[n]:
151
+ copy_rings[n].remove(m)
152
+ copy_rings[m].remove(n)
153
+ for n, m in zip(r, r[1:]):
154
+ if n not in rings[m]:
155
+ if n in double_bonded and m in double_bonded and m in double_bonded[n]:
156
+ double_bonded[n].remove(m)
157
+ double_bonded[m].remove(n)
158
+ rings[m].append(n)
159
+ rings[n].append(m)
160
+ elif m in copy_rings[n]:
161
+ copy_rings[n].remove(m)
162
+ copy_rings[m].remove(n)
163
+
164
+ # fix invalid smiles: c1ccccc1c2ccccc2 instead of c1ccccc1-c2ccccc2
165
+ seen = set()
166
+ for n, ms in copy_rings.items():
167
+ if ms:
168
+ seen.add(n)
169
+ for m in ms:
170
+ if m not in seen:
171
+ rings[n].remove(m)
172
+ rings[m].remove(n)
173
+ bonds[n][m]._order = 1
174
+
175
+ if any(len(ms) not in (2, 3) for ms in rings.values()):
176
+ raise InvalidAromaticRing('not in ring aromatic bond or hypercondensed rings: '
177
+ f'{{{", ".join(str(n) for n, ms in rings.items() if len(ms) not in (2, 3))}}}')
178
+
179
+ # get double bonded ring atoms
180
+ double_bonded = {n for n, ms in double_bonded.items() if ms and n in rings}
181
+ if any(len(rings[n]) != 2 for n in double_bonded): # double bonded never condensed
182
+ raise InvalidAromaticRing('quinone valence error')
183
+ for n in double_bonded:
184
+ if (atom := atoms[n]) == N:
185
+ if atom.charge != 1:
186
+ raise InvalidAromaticRing('quinone should be charged N atom')
187
+ elif atom not in (C, P, S, As, Se, Te) or atom.charge:
188
+ raise InvalidAromaticRing('quinone should be neutral S, Se, Te, C, P, As atom')
189
+
190
+ for n in rings:
191
+ if (atom := atoms[n]) == C: # carbon
192
+ if atom.charge == 0:
193
+ if atom.neighbors not in (2, 3):
194
+ raise InvalidAromaticRing
195
+ elif atom.charge in (-1, 1):
196
+ if atom.is_radical:
197
+ if atom.neighbors == 2:
198
+ double_bonded.add(n)
199
+ else:
200
+ raise InvalidAromaticRing
201
+ elif atom.neighbors == 3:
202
+ double_bonded.add(n)
203
+ elif atom.neighbors == 2: # benzene (an|cat)ion or pyrrole
204
+ pyrroles.add(n)
205
+ else:
206
+ raise InvalidAromaticRing
207
+ else:
208
+ raise InvalidAromaticRing
209
+ elif atom in (N, P, As):
210
+ if atom.charge == 0: # pyrrole or pyridine. include radical pyrrole
211
+ if atom.is_radical:
212
+ if atom.neighbors != 2: # only pyrrole radical
213
+ raise InvalidAromaticRing
214
+ double_bonded.add(n)
215
+ elif atom.neighbors == 3:
216
+ if atom == N: # pyrrole only possible
217
+ double_bonded.add(n)
218
+ else: # P(III) or P(V)H
219
+ pyrroles.add(n)
220
+ elif atom.neighbors == 2:
221
+ if atom.implicit_hydrogens is None: # pyrrole or pyridine
222
+ pyrroles.add(n)
223
+ elif atom.implicit_hydrogens == 1: # only pyrrole
224
+ if ignore_pyrrole_hydrogen:
225
+ pyrroles.add(n)
226
+ else:
227
+ double_bonded.add(n)
228
+ elif atom.implicit_hydrogens: # too many hydrogens for aromatic rings
229
+ raise InvalidAromaticRing
230
+ elif atom.neighbors != 4 or atom not in (P, As): # P(V) in ring [P;a](-R1)-R2
231
+ raise InvalidAromaticRing
232
+ elif atom.charge == -1: # pyrrole only
233
+ if atom.neighbors != 2 or atom.is_radical:
234
+ raise InvalidAromaticRing
235
+ double_bonded.add(n)
236
+ elif atom.charge != 1:
237
+ raise InvalidAromaticRing
238
+ elif atom.is_radical:
239
+ if atom.neighbors != 2: # not cation-radical pyridine
240
+ raise InvalidAromaticRing
241
+ elif atom.neighbors == 2: # pyrrole cation or protonated pyridine
242
+ pyrroles.add(n)
243
+ elif atom.neighbors != 3: # not pyridine oxyde
244
+ raise InvalidAromaticRing
245
+ elif atom == O: # furan
246
+ if atom.neighbors == 2:
247
+ if atom.charge == 0:
248
+ if atom.is_radical:
249
+ raise InvalidAromaticRing('radical oxygen')
250
+ double_bonded.add(n)
251
+ elif atom.charge == 1:
252
+ if atom.is_radical: # furan cation-radical
253
+ double_bonded.add(n)
254
+ # pyrylium
255
+ else:
256
+ raise InvalidAromaticRing('invalid oxygen charge')
257
+ else:
258
+ raise InvalidAromaticRing('Triple-bonded oxygen')
259
+ elif atom in (S, Se, Te): # thiophene
260
+ if n not in double_bonded: # not sulphoxyde nor sulphone
261
+ if atom.neighbors == 2:
262
+ if atom.is_radical:
263
+ if atom.charge == 1:
264
+ double_bonded.add(n)
265
+ else:
266
+ raise InvalidAromaticRing('S, Se, Te cation-radical expected')
267
+ if atom.charge == 0:
268
+ double_bonded.add(n)
269
+ elif atom.charge != 1:
270
+ raise InvalidAromaticRing('S, Se, Te cation in benzene like ring expected')
271
+ elif atom.neighbors == 3:
272
+ if atom.is_radical:
273
+ if atom.charge:
274
+ raise InvalidAromaticRing('S, Se, Te ion-radical ring')
275
+ double_bonded.add(n)
276
+ elif atom.charge == 1:
277
+ double_bonded.add(n)
278
+ elif atom.charge:
279
+ raise InvalidAromaticRing('S, Se, Te invalid charge ring')
280
+ else:
281
+ raise InvalidAromaticRing('S, Se, Te hypervalent ring')
282
+ elif atom == B:
283
+ if atom.charge == 0:
284
+ if atom.neighbors == 2:
285
+ if atom.is_radical: # C=1O[B]OC=1
286
+ double_bonded.add(n)
287
+ else:
288
+ if atom.implicit_hydrogens is None: # b1ccccc1, C=1OBOC=1 or B1C=CC=N1
289
+ pyrroles.add(n)
290
+ elif atom.implicit_hydrogens == 1: # C=1O[BH]OC=1 or [BH]1C=CC=N1
291
+ double_bonded.add(n)
292
+ elif atom.implicit_hydrogens:
293
+ raise InvalidAromaticRing
294
+ elif not atom.is_radical:
295
+ double_bonded.add(n)
296
+ else:
297
+ raise InvalidAromaticRing
298
+ elif atom.charge == 1:
299
+ if atom.neighbors == 2 and not atom.is_radical:
300
+ double_bonded.add(n)
301
+ else:
302
+ raise InvalidAromaticRing
303
+ elif atom.charge == -1:
304
+ if atom.neighbors == 2:
305
+ if not atom.is_radical: # C=1O[B-]OC=1 or [bH-]1ccccc1
306
+ pyrroles.add(n)
307
+ # anion-radical is benzene like
308
+ elif atom.is_radical: # C=1O[B-*](R)OC=1
309
+ double_bonded.add(n)
310
+ else:
311
+ pyrroles.add(n)
312
+ else:
313
+ raise InvalidAromaticRing
314
+ else:
315
+ raise InvalidAromaticRing(f'only B, C, N, P, O, S, Se, Te possible, not: {atoms[n].atomic_symbol}')
316
+ return rings, pyrroles, double_bonded
317
+
318
+ def __kekule_full(self, buffer_size, ignore_pyrrole_hydrogen):
319
+ rings, pyrroles, double_bonded = self.__prepare_rings(ignore_pyrrole_hydrogen)
320
+ atoms = set(rings)
321
+ components = []
322
+ while atoms:
323
+ start = atoms.pop()
324
+ component = {start: rings[start]}
325
+ queue = deque([start])
326
+ while queue:
327
+ current = queue.popleft()
328
+ for n in rings[current]:
329
+ if n not in component:
330
+ queue.append(n)
331
+ component[n] = rings[n]
332
+
333
+ components.append(component)
334
+ atoms.difference_update(component)
335
+
336
+ for keks in lazy_product(*(_kekule_component(c, double_bonded & c.keys(), pyrroles & c.keys(), buffer_size)
337
+ for c in components)):
338
+ yield [x for x in keks for x in x]
339
+
340
+
341
+ def _kekule_component(rings, double_bonded, pyrroles, buffer_size):
342
+ # (current atom, previous atom, bond between cp atoms, path deep for cutting [None if cut impossible])
343
+ stack: list[list[tuple[int, int, int, int | None]]]
344
+ if double_bonded: # start from double bonded if exists
345
+ start = next(iter(double_bonded))
346
+ stack = [[(next(iter(rings[start])), start, 1, 0)]]
347
+ else: # select not pyrrole not condensed atom
348
+ try:
349
+ start = next(n for n, ms in rings.items() if len(ms) == 2 and n not in pyrroles)
350
+ except StopIteration: # all pyrroles. select not condensed atom.
351
+ try:
352
+ start = next(n for n, ms in rings.items() if len(ms) == 2)
353
+ except StopIteration: # fullerene?
354
+ start = next(iter(rings))
355
+ double_bonded.add(start)
356
+ stack = [[(next_atom, start, 2, 0)] for next_atom in rings[start]]
357
+ else:
358
+ stack = [[(next_atom, start, 1, 0)] for next_atom in rings[start]]
359
+ else:
360
+ stack = [[(next_atom, start, 1, 0)] for next_atom in rings[start]]
361
+
362
+ size = sum(len(x) for x in rings.values()) // 2
363
+ path = []
364
+ hashed_path = set()
365
+ nether_yielded = True
366
+ buffer = []
367
+
368
+ while stack:
369
+ atom, prev_atom, bond, _ = stack[-1].pop()
370
+ path.append((atom, prev_atom, bond))
371
+ hashed_path.add(atom)
372
+
373
+ if len(path) == size:
374
+ if nether_yielded:
375
+ nether_yielded = False
376
+ if pyrroles and buffer_size: # prioritize pyridine over pyrrole
377
+ g = defaultdict(int)
378
+ for n, m, b in path:
379
+ g[n] += b
380
+ g[m] += b
381
+ # should be pairs of pyrrole atoms
382
+ if sum(b == 2 and n in pyrroles for n, b in g.items()) >= 2:
383
+ if len(buffer) == buffer_size: # optimization. try only few times to prevent freezes.
384
+ buffer_size = 0 # disable bufferization
385
+ yield from buffer
386
+ yield path
387
+ buffer = []
388
+ else:
389
+ buffer.append(path)
390
+ else:
391
+ yield path
392
+ buffer_size = 0 # disable bufferization
393
+ if buffer: # empty buffer
394
+ yield from buffer
395
+ buffer = []
396
+ else:
397
+ yield path
398
+
399
+ del stack[-1]
400
+ if stack:
401
+ path = path[:stack[-1][-1][-1]]
402
+ hashed_path = {x for x, *_ in path}
403
+ elif atom != start:
404
+ for_stack = []
405
+ closures = []
406
+ loop = 0
407
+ for next_atom in rings[atom]:
408
+ if next_atom == prev_atom: # only forward. behind us is the homeland
409
+ continue
410
+ elif next_atom == start:
411
+ loop = next_atom
412
+ elif next_atom in hashed_path: # closure found
413
+ closures.append(next_atom)
414
+ else:
415
+ for_stack.append(next_atom)
416
+
417
+ if loop: # we found starting point.
418
+ if bond == 2: # finish should be single bonded
419
+ if double_bonded: # ok
420
+ stack[-1].insert(0, (loop, atom, 1, None))
421
+ else:
422
+ del stack[-1]
423
+ if stack:
424
+ path = path[:stack[-1][-1][-1]]
425
+ hashed_path = {x for x, *_ in path}
426
+ continue
427
+ elif double_bonded: # we in quinone ring. finish should be single bonded
428
+ # side-path for storing double bond or atom is quinone or pyrrole
429
+ if for_stack or atom in double_bonded or atom in pyrroles:
430
+ stack[-1].insert(0, (loop, atom, 1, None))
431
+ else:
432
+ del stack[-1]
433
+ if stack:
434
+ path = path[:stack[-1][-1][-1]]
435
+ hashed_path = {x for x, *_ in path}
436
+ continue
437
+ else: # finish should be double bonded
438
+ stack[-1].insert(0, (loop, atom, 2, None))
439
+ bond = 2 # grow should be single bonded
440
+
441
+ if bond == 2 or atom in double_bonded: # double in - single out. quinone has two single bonds
442
+ for next_atom in closures:
443
+ path.append((next_atom, atom, 1)) # closures always single-bonded
444
+ stack[-1].remove((atom, next_atom, 1, None)) # remove fork from stack
445
+ for next_atom in for_stack:
446
+ stack[-1].append((next_atom, atom, 1, None))
447
+ elif len(for_stack) == 1: # easy path grow. next bond double or include single for pyrroles
448
+ next_atom = for_stack[0]
449
+ if next_atom in double_bonded: # need double bond, but next atom quinone
450
+ if atom in pyrroles:
451
+ stack[-1].append((next_atom, atom, 1, None))
452
+ else:
453
+ del stack[-1]
454
+ if stack:
455
+ path = path[:stack[-1][-1][-1]]
456
+ hashed_path = {x for x, *_ in path}
457
+ elif atom in pyrroles: # try pyrrole and pyridine
458
+ opposite = stack[-1].copy()
459
+ opposite.append((next_atom, atom, 2, None))
460
+ stack[-1].append((next_atom, atom, 1, len(path)))
461
+ stack.append(opposite)
462
+ else:
463
+ stack[-1].append((next_atom, atom, 2, None))
464
+ if closures:
465
+ next_atom = closures[0]
466
+ path.append((next_atom, atom, 1)) # closures always single-bonded
467
+ stack[-1].remove((atom, next_atom, 1, None)) # remove fork from stack
468
+ elif for_stack: # fork
469
+ next_atom1, next_atom2 = for_stack
470
+ if next_atom1 in double_bonded: # quinone next from fork
471
+ if next_atom2 in double_bonded:
472
+ if atom in pyrroles: # shit like O=C1C=CC2=CC=CC3=C2P1C(=O)C=C3
473
+ stack[-1].append((next_atom1, atom, 1, None))
474
+ stack[-1].append((next_atom2, atom, 1, None))
475
+ else: # bad path
476
+ del stack[-1]
477
+ if stack:
478
+ path = path[:stack[-1][-1][-1]]
479
+ hashed_path = {x for x, *_ in path}
480
+ elif atom in pyrroles: # O=C1C=CC2=CC=CC3=C2P1C=C3 or O=C1C=CC2=CC=CC3=C2P1=CC=C3
481
+ opposite = stack[-1].copy()
482
+ opposite.append((next_atom1, atom, 1, None))
483
+ opposite.append((next_atom2, atom, 2, None))
484
+ stack[-1].append((next_atom1, atom, 1, None))
485
+ stack[-1].append((next_atom2, atom, 1, len(path)))
486
+ stack.append(opposite) # pyridine first
487
+ else: # normal condensed ring
488
+ stack[-1].append((next_atom1, atom, 1, None))
489
+ stack[-1].append((next_atom2, atom, 2, None))
490
+ elif next_atom2 in double_bonded: # quinone next from fork
491
+ if atom in pyrroles:
492
+ opposite = stack[-1].copy()
493
+ opposite.append((next_atom2, atom, 1, None))
494
+ opposite.append((next_atom1, atom, 2, None))
495
+ stack[-1].append((next_atom1, atom, 1, None))
496
+ stack[-1].append((next_atom2, atom, 1, len(path)))
497
+ stack.append(opposite)
498
+ else:
499
+ stack[-1].append((next_atom2, atom, 1, None))
500
+ stack[-1].append((next_atom1, atom, 2, None))
501
+ elif atom in pyrroles: # C1=CC2=CC=CC3=C2P1C=C3 or C1=CP2=CC=CC3=C2C1=CC=C3
502
+ opposite1 = stack[-1].copy()
503
+ opposite1.append((next_atom2, atom, 1, None))
504
+ opposite1.append((next_atom1, atom, 2, len(path)))
505
+ opposite2 = stack[-1].copy()
506
+ opposite2.append((next_atom1, atom, 1, None))
507
+ opposite2.append((next_atom2, atom, 2, None))
508
+
509
+ stack[-1].append((next_atom1, atom, 1, None))
510
+ stack[-1].append((next_atom2, atom, 1, len(path)))
511
+ stack.append(opposite1)
512
+ stack.append(opposite2)
513
+ else: # new path
514
+ opposite = stack[-1].copy()
515
+ stack[-1].append((next_atom1, atom, 1, None))
516
+ stack[-1].append((next_atom2, atom, 2, len(path))) # double bond on top of stack
517
+ opposite.append((next_atom2, atom, 1, None))
518
+ opposite.append((next_atom1, atom, 2, None))
519
+ stack.append(opposite)
520
+ elif closures and atom not in pyrroles: # need double bond, but closure should be single bonded
521
+ del stack[-1]
522
+ if stack:
523
+ path = path[:stack[-1][-1][-1]]
524
+ hashed_path = {x for x, *_ in path}
525
+
526
+ if nether_yielded:
527
+ raise InvalidAromaticRing(f'kekule form not found for: {list(rings)}')
528
+ elif buffer: # optimal solution not found. return available.
529
+ yield from buffer
530
+
531
+
532
+ __all__ = ['Kekule']
@@ -0,0 +1,18 @@
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
+ #