chython 3.0__cp313-cp313-macosx_10_13_universal2.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 (414) hide show
  1. chython/__init__.py +68 -0
  2. chython/_functions.py +113 -0
  3. chython/chemistry/__init__.py +86 -0
  4. chython/chemistry/_abbreviations.py +127 -0
  5. chython/chemistry/_canonicalize.py +152 -0
  6. chython/chemistry/_counts.py +81 -0
  7. chython/chemistry/_crippen.py +93 -0
  8. chython/chemistry/_hydrogens.py +174 -0
  9. chython/chemistry/_implicit.py +87 -0
  10. chython/chemistry/_isomers.py +564 -0
  11. chython/chemistry/_maccs.py +156 -0
  12. chython/chemistry/_perceive.py +164 -0
  13. chython/chemistry/_pharmacophore.py +83 -0
  14. chython/chemistry/_protomers.py +215 -0
  15. chython/chemistry/_qed.py +137 -0
  16. chython/chemistry/_residues.py +192 -0
  17. chython/chemistry/_resonance.py +393 -0
  18. chython/chemistry/_salts.py +308 -0
  19. chython/chemistry/_saturate.py +472 -0
  20. chython/chemistry/_smarts.py +75 -0
  21. chython/chemistry/_standardize.py +149 -0
  22. chython/chemistry/_tables.py +1139 -0
  23. chython/chemistry/_tpsa.py +69 -0
  24. chython/chemistry/tables/abbreviations.tsv +93 -0
  25. chython/chemistry/tables/acids.tsv +37 -0
  26. chython/chemistry/tables/covalent_radii.tsv +109 -0
  27. chython/chemistry/tables/crippen.tsv +146 -0
  28. chython/chemistry/tables/hbond.tsv +29 -0
  29. chython/chemistry/tables/maccs.tsv +218 -0
  30. chython/chemistry/tables/maccs_corpus.tsv +342 -0
  31. chython/chemistry/tables/pharmacophore.tsv +21 -0
  32. chython/chemistry/tables/qed_alerts.tsv +86 -0
  33. chython/chemistry/tables/residues.tsv +119 -0
  34. chython/chemistry/tables/resonance.tsv +58 -0
  35. chython/chemistry/tables/rotatable.tsv +11 -0
  36. chython/chemistry/tables/salts.tsv +162 -0
  37. chython/chemistry/tables/standardize_groups.tsv +165 -0
  38. chython/chemistry/tables/standardize_metals.tsv +41 -0
  39. chython/chemistry/tables/sybyl_types.tsv +75 -0
  40. chython/chemistry/tables/tpsa.tsv +56 -0
  41. chython/chemistry/test/__init__.py +18 -0
  42. chython/chemistry/test/_corpus.py +110 -0
  43. chython/chemistry/test/_oracle.py +49 -0
  44. chython/chemistry/test/gen_standardize_rules.py +538 -0
  45. chython/chemistry/test/test_abbreviations.py +231 -0
  46. chython/chemistry/test/test_acids_tsv.py +121 -0
  47. chython/chemistry/test/test_canonicalize.py +606 -0
  48. chython/chemistry/test/test_counts.py +221 -0
  49. chython/chemistry/test/test_covalent_radii_tsv.py +179 -0
  50. chython/chemistry/test/test_crippen.py +159 -0
  51. chython/chemistry/test/test_crippen_tsv.py +168 -0
  52. chython/chemistry/test/test_dependency_direction.py +164 -0
  53. chython/chemistry/test/test_featurizer_injection.py +100 -0
  54. chython/chemistry/test/test_featurizer_tables_lazy.py +80 -0
  55. chython/chemistry/test/test_isomers.py +390 -0
  56. chython/chemistry/test/test_maccs.py +135 -0
  57. chython/chemistry/test/test_maccs_corpus.py +74 -0
  58. chython/chemistry/test/test_maccs_tsv.py +123 -0
  59. chython/chemistry/test/test_perceive.py +226 -0
  60. chython/chemistry/test/test_pharmacophore.py +236 -0
  61. chython/chemistry/test/test_protomers.py +229 -0
  62. chython/chemistry/test/test_qed.py +128 -0
  63. chython/chemistry/test/test_qed_alerts_tsv.py +102 -0
  64. chython/chemistry/test/test_reaction_hydrogen_repair.py +78 -0
  65. chython/chemistry/test/test_reaction_passes.py +158 -0
  66. chython/chemistry/test/test_residues.py +532 -0
  67. chython/chemistry/test/test_resonance.py +288 -0
  68. chython/chemistry/test/test_resonance_tsv.py +95 -0
  69. chython/chemistry/test/test_salts.py +565 -0
  70. chython/chemistry/test/test_saturate.py +737 -0
  71. chython/chemistry/test/test_smarts.py +309 -0
  72. chython/chemistry/test/test_standardize_differential.py +165 -0
  73. chython/chemistry/test/test_standardize_groups_port.py +371 -0
  74. chython/chemistry/test/test_standardize_overvalent_nitrogen.py +157 -0
  75. chython/chemistry/test/test_standardize_rules_examples.py +170 -0
  76. chython/chemistry/test/test_standardize_rules_merges.py +187 -0
  77. chython/chemistry/test/test_standardize_rules_tsv.py +266 -0
  78. chython/chemistry/test/test_thiele_is_single_purpose.py +133 -0
  79. chython/chemistry/test/test_tpsa.py +136 -0
  80. chython/chemistry/test/test_tpsa_tsv.py +134 -0
  81. chython/chemistry/test/test_valence_report.py +131 -0
  82. chython/chemistry/test/test_z_translation.py +178 -0
  83. chython/core/RULES.md +1046 -0
  84. chython/core/__init__.py +151 -0
  85. chython/core/_core.cpython-313-darwin.so +0 -0
  86. chython/core/_facade.py +183 -0
  87. chython/core/_log.py +268 -0
  88. chython/core/_reaction_passes.py +606 -0
  89. chython/core/elements.tsv +167 -0
  90. chython/core/isotopes.tsv +475 -0
  91. chython/core/libinchi.dylib +0 -0
  92. chython/core/reaction.py +1213 -0
  93. chython/core/test/__init__.py +18 -0
  94. chython/core/test/arena_v4_corpus.bin.gz +0 -0
  95. chython/core/test/bench_ml.py +123 -0
  96. chython/core/test/chytorch_oracle.py +162 -0
  97. chython/core/test/gen_element_tables.py +339 -0
  98. chython/core/test/gen_modeling_view_corpus.py +55 -0
  99. chython/core/test/gen_pach3_corpus.py +49 -0
  100. chython/core/test/gen_reaction_pach_corpus.py +153 -0
  101. chython/core/test/gen_v3_fixtures.py +250 -0
  102. chython/core/test/gen_v4_fixtures.py +116 -0
  103. chython/core/test/gen_valence_rules.py +761 -0
  104. chython/core/test/modeling_view_corpus.json.gz +0 -0
  105. chython/core/test/modeling_view_corpus.py +108 -0
  106. chython/core/test/oracle.py +707 -0
  107. chython/core/test/pach3_corpus.py +169 -0
  108. chython/core/test/pach_corpus.py +108 -0
  109. chython/core/test/pach_v0_corpus.bin.gz +0 -0
  110. chython/core/test/pach_v0_native_corpus.bin.gz +0 -0
  111. chython/core/test/pach_v2_corpus.bin.gz +0 -0
  112. chython/core/test/pach_v3_corpus.bin.gz +0 -0
  113. chython/core/test/pach_v4_corpus.bin.gz +0 -0
  114. chython/core/test/reaction_pach_corpus.py +97 -0
  115. chython/core/test/reaction_pach_v2_corpus.bin.gz +0 -0
  116. chython/core/test/test_aggregates.py +199 -0
  117. chython/core/test/test_alternative_spellings.py +191 -0
  118. chython/core/test/test_apply_scratch_probe.py +235 -0
  119. chython/core/test/test_arena_f60.py +143 -0
  120. chython/core/test/test_arena_identity.py +367 -0
  121. chython/core/test/test_arena_v3_compat.py +392 -0
  122. chython/core/test/test_arena_v4_compat.py +100 -0
  123. chython/core/test/test_aromatic_storage.py +643 -0
  124. chython/core/test/test_canonical.py +634 -0
  125. chython/core/test/test_canonical_mirror.py +503 -0
  126. chython/core/test/test_cip_storage.py +720 -0
  127. chython/core/test/test_clean_isotopes_and_coordinate_bonds.py +207 -0
  128. chython/core/test/test_clean_stereo.py +253 -0
  129. chython/core/test/test_conformers.py +704 -0
  130. chython/core/test/test_container_log.py +96 -0
  131. chython/core/test/test_copy_caches.py +139 -0
  132. chython/core/test/test_derive.py +177 -0
  133. chython/core/test/test_descriptors.py +1457 -0
  134. chython/core/test/test_element_tables.py +470 -0
  135. chython/core/test/test_facade.py +288 -0
  136. chython/core/test/test_features.py +675 -0
  137. chython/core/test/test_featurizer_injection.py +52 -0
  138. chython/core/test/test_fingerprints.py +620 -0
  139. chython/core/test/test_geometry.py +216 -0
  140. chython/core/test/test_h_unknown.py +458 -0
  141. chython/core/test/test_hydrogens.py +320 -0
  142. chython/core/test/test_inchi.py +797 -0
  143. chython/core/test/test_interop_injection.py +125 -0
  144. chython/core/test/test_isomorphism.py +1073 -0
  145. chython/core/test/test_kekule.py +1201 -0
  146. chython/core/test/test_log.py +300 -0
  147. chython/core/test/test_magic.py +792 -0
  148. chython/core/test/test_meta.py +76 -0
  149. chython/core/test/test_ml_encoding.py +151 -0
  150. chython/core/test/test_ml_reaction_transition.py +288 -0
  151. chython/core/test/test_ml_state_view.py +263 -0
  152. chython/core/test/test_ml_transition_view.py +180 -0
  153. chython/core/test/test_ml_unpack_differential.py +125 -0
  154. chython/core/test/test_modeling_view_frozen.py +86 -0
  155. chython/core/test/test_molecule.py +1138 -0
  156. chython/core/test/test_morgan.py +387 -0
  157. chython/core/test/test_no_chython_two_imports.py +223 -0
  158. chython/core/test/test_oracle.py +238 -0
  159. chython/core/test/test_pach.py +994 -0
  160. chython/core/test/test_pach3.py +1395 -0
  161. chython/core/test/test_pack.py +821 -0
  162. chython/core/test/test_query.py +1456 -0
  163. chython/core/test/test_r_edit.py +92 -0
  164. chython/core/test/test_r_query.py +72 -0
  165. chython/core/test/test_r_semantics.py +308 -0
  166. chython/core/test/test_r_serialisation.py +73 -0
  167. chython/core/test/test_r_smirks.py +118 -0
  168. chython/core/test/test_r_storage.py +205 -0
  169. chython/core/test/test_reaction_container.py +489 -0
  170. chython/core/test/test_reaction_identity.py +217 -0
  171. chython/core/test/test_reaction_pach.py +780 -0
  172. chython/core/test/test_reaction_passes.py +346 -0
  173. chython/core/test/test_reaction_patch_stereo.py +162 -0
  174. chython/core/test/test_reaction_smiles.py +333 -0
  175. chython/core/test/test_rings.py +812 -0
  176. chython/core/test/test_rings_c60.py +60 -0
  177. chython/core/test/test_set_element.py +156 -0
  178. chython/core/test/test_sgroups.py +607 -0
  179. chython/core/test/test_smarts_read.py +1135 -0
  180. chython/core/test/test_smiles_r.py +161 -0
  181. chython/core/test/test_smiles_read.py +1193 -0
  182. chython/core/test/test_smiles_roundtrip.py +345 -0
  183. chython/core/test/test_smiles_write.py +721 -0
  184. chython/core/test/test_smiles_write_aromatic.py +361 -0
  185. chython/core/test/test_smiles_write_cis_trans.py +960 -0
  186. chython/core/test/test_smiles_write_detached.py +674 -0
  187. chython/core/test/test_smiles_write_differential.py +814 -0
  188. chython/core/test/test_smiles_write_h_unknown.py +373 -0
  189. chython/core/test/test_smiles_write_stereo.py +559 -0
  190. chython/core/test/test_smiles_write_sticky.py +441 -0
  191. chython/core/test/test_smirks_filter.py +215 -0
  192. chython/core/test/test_smirks_patch.py +547 -0
  193. chython/core/test/test_smirks_read.py +491 -0
  194. chython/core/test/test_smirks_report.py +73 -0
  195. chython/core/test/test_smirks_stereo.py +986 -0
  196. chython/core/test/test_stereo_acceptance.py +583 -0
  197. chython/core/test/test_stereo_parity.py +2135 -0
  198. chython/core/test/test_stereo_perception.py +1453 -0
  199. chython/core/test/test_stereo_query.py +1694 -0
  200. chython/core/test/test_stereo_units.py +1192 -0
  201. chython/core/test/test_stereo_v2_differential.py +226 -0
  202. chython/core/test/test_structure.py +318 -0
  203. chython/core/test/test_thiele.py +698 -0
  204. chython/core/test/test_title.py +110 -0
  205. chython/core/test/test_topology.py +405 -0
  206. chython/core/test/test_union_stereo.py +167 -0
  207. chython/core/test/test_valence.py +756 -0
  208. chython/core/test/test_view_surface.py +320 -0
  209. chython/core/test/v3_fixtures.py +221 -0
  210. chython/core/test/v4_fixtures.py +33 -0
  211. chython/core/valence_rules.tsv +1089 -0
  212. chython/core/wedge.py +1510 -0
  213. chython/depict/__init__.py +52 -0
  214. chython/depict/_config.py +78 -0
  215. chython/depict/_hooks.py +41 -0
  216. chython/depict/bonds.py +671 -0
  217. chython/depict/colorbar.py +145 -0
  218. chython/depict/colormap.py +302 -0
  219. chython/depict/field.py +686 -0
  220. chython/depict/figure.py +300 -0
  221. chython/depict/label.py +500 -0
  222. chython/depict/layout/__init__.py +28 -0
  223. chython/depict/layout/clean2d.js +3 -0
  224. chython/depict/layout/molecule.py +466 -0
  225. chython/depict/layout/reaction.py +118 -0
  226. chython/depict/metrics/__init__.py +161 -0
  227. chython/depict/metrics/helvetica.tsv +332 -0
  228. chython/depict/metrics/times.tsv +332 -0
  229. chython/depict/overlay.py +752 -0
  230. chython/depict/render/__init__.py +28 -0
  231. chython/depict/render/svg.py +216 -0
  232. chython/depict/scene.py +526 -0
  233. chython/depict/style.py +457 -0
  234. chython/depict/test/__init__.py +18 -0
  235. chython/depict/test/test_bonds.py +1223 -0
  236. chython/depict/test/test_clean2d.py +713 -0
  237. chython/depict/test/test_colorbar.py +276 -0
  238. chython/depict/test/test_colormap.py +201 -0
  239. chython/depict/test/test_field.py +519 -0
  240. chython/depict/test/test_figure.py +957 -0
  241. chython/depict/test/test_label.py +803 -0
  242. chython/depict/test/test_metrics.py +178 -0
  243. chython/depict/test/test_overlay.py +469 -0
  244. chython/depict/test/test_package.py +276 -0
  245. chython/depict/test/test_r_atom.py +80 -0
  246. chython/depict/test/test_scene.py +341 -0
  247. chython/depict/test/test_style.py +228 -0
  248. chython/depict/test/test_svg.py +358 -0
  249. chython/depict/test/test_wedge_draw.py +1049 -0
  250. chython/depict/test/test_x3dom.py +179 -0
  251. chython/depict/wedge.py +383 -0
  252. chython/depict/x3dom.py +372 -0
  253. chython/exceptions.py +154 -0
  254. chython/formats/__init__.py +68 -0
  255. chython/formats/_text.py +33 -0
  256. chython/formats/ctfile/CTFILE.md +646 -0
  257. chython/formats/ctfile/__init__.py +53 -0
  258. chython/formats/ctfile/_ctab.py +481 -0
  259. chython/formats/ctfile/_errors.py +40 -0
  260. chython/formats/ctfile/_facade.py +164 -0
  261. chython/formats/ctfile/_hydrogens.py +504 -0
  262. chython/formats/ctfile/_rdf.py +688 -0
  263. chython/formats/ctfile/_rxn.py +389 -0
  264. chython/formats/ctfile/_sdf.py +245 -0
  265. chython/formats/ctfile/_sgroup.py +546 -0
  266. chython/formats/ctfile/_stream.py +307 -0
  267. chython/formats/ctfile/_tokens.py +321 -0
  268. chython/formats/ctfile/_v2000.py +958 -0
  269. chython/formats/ctfile/_v3000.py +848 -0
  270. chython/formats/ctfile/test/__init__.py +0 -0
  271. chython/formats/ctfile/test/conftest.py +112 -0
  272. chython/formats/ctfile/test/test_aromatic.py +345 -0
  273. chython/formats/ctfile/test/test_container_log.py +229 -0
  274. chython/formats/ctfile/test/test_data_labels.py +180 -0
  275. chython/formats/ctfile/test/test_facade.py +341 -0
  276. chython/formats/ctfile/test/test_fidelity.py +529 -0
  277. chython/formats/ctfile/test/test_hydrogens.py +887 -0
  278. chython/formats/ctfile/test/test_r_atom.py +330 -0
  279. chython/formats/ctfile/test/test_rdf.py +1605 -0
  280. chython/formats/ctfile/test/test_rxn.py +404 -0
  281. chython/formats/ctfile/test/test_sdf.py +300 -0
  282. chython/formats/ctfile/test/test_sgroup.py +436 -0
  283. chython/formats/ctfile/test/test_stream.py +379 -0
  284. chython/formats/ctfile/test/test_tokens.py +272 -0
  285. chython/formats/ctfile/test/test_v2000.py +608 -0
  286. chython/formats/ctfile/test/test_v3000.py +746 -0
  287. chython/formats/ctfile/test/test_wedge.py +2274 -0
  288. chython/formats/mol2.py +812 -0
  289. chython/formats/pdb/__init__.py +36 -0
  290. chython/formats/pdb/_builder.py +632 -0
  291. chython/formats/pdb/_legacy.py +519 -0
  292. chython/formats/pdb/_mmcif.py +666 -0
  293. chython/formats/pdb/_records.py +242 -0
  294. chython/formats/pdb/_star.py +453 -0
  295. chython/formats/test/__init__.py +18 -0
  296. chython/formats/test/conftest.py +76 -0
  297. chython/formats/test/oracles.py +1995 -0
  298. chython/formats/test/test_conformance.py +638 -0
  299. chython/formats/test/test_done_when.py +84 -0
  300. chython/formats/test/test_isolation.py +100 -0
  301. chython/formats/test/test_log_prefix.py +199 -0
  302. chython/formats/test/test_mmcif.py +1084 -0
  303. chython/formats/test/test_mol2.py +1360 -0
  304. chython/formats/test/test_no_review_bookkeeping.py +96 -0
  305. chython/formats/test/test_oracles.py +133 -0
  306. chython/formats/test/test_pdb.py +726 -0
  307. chython/formats/test/test_pdb_builder.py +924 -0
  308. chython/formats/test/test_perceive_agreement.py +116 -0
  309. chython/formats/test/test_read_only_facades.py +96 -0
  310. chython/formats/test/test_xyz.py +869 -0
  311. chython/formats/test/test_xyz_builder.py +149 -0
  312. chython/formats/xml/__init__.py +50 -0
  313. chython/formats/xml/_cml.py +972 -0
  314. chython/formats/xml/_dialect.py +958 -0
  315. chython/formats/xml/_errors.py +46 -0
  316. chython/formats/xml/_facade.py +77 -0
  317. chython/formats/xml/_mrv.py +1243 -0
  318. chython/formats/xml/_tree.py +315 -0
  319. chython/formats/xml/test/__init__.py +0 -0
  320. chython/formats/xml/test/conftest.py +79 -0
  321. chython/formats/xml/test/test_cml.py +1282 -0
  322. chython/formats/xml/test/test_container_log.py +112 -0
  323. chython/formats/xml/test/test_dialect.py +729 -0
  324. chython/formats/xml/test/test_equivalence.py +346 -0
  325. chython/formats/xml/test/test_facade.py +96 -0
  326. chython/formats/xml/test/test_mrv.py +1250 -0
  327. chython/formats/xml/test/test_mrv_census.py +459 -0
  328. chython/formats/xml/test/test_tree.py +355 -0
  329. chython/formats/xyz.py +520 -0
  330. chython/interop/__init__.py +179 -0
  331. chython/interop/_cdk.py +527 -0
  332. chython/interop/_cdpkit.py +191 -0
  333. chython/interop/_indigo.py +322 -0
  334. chython/interop/_iupac.py +94 -0
  335. chython/interop/_java.py +65 -0
  336. chython/interop/_openbabel.py +334 -0
  337. chython/interop/_pandas.py +54 -0
  338. chython/interop/_rdkit.py +638 -0
  339. chython/interop/_records.py +62 -0
  340. chython/interop/_stereo.py +140 -0
  341. chython/interop/config.py +93 -0
  342. chython/interop/conformers.py +143 -0
  343. chython/interop/test/__init__.py +0 -0
  344. chython/interop/test/conftest.py +79 -0
  345. chython/interop/test/test_cdk.py +358 -0
  346. chython/interop/test/test_cdpkit.py +458 -0
  347. chython/interop/test/test_config.py +181 -0
  348. chython/interop/test/test_conformers.py +248 -0
  349. chython/interop/test/test_coordinate_honesty.py +55 -0
  350. chython/interop/test/test_dispatch.py +189 -0
  351. chython/interop/test/test_indigo.py +553 -0
  352. chython/interop/test/test_iupac.py +147 -0
  353. chython/interop/test/test_log_delivery.py +189 -0
  354. chython/interop/test/test_openbabel.py +429 -0
  355. chython/interop/test/test_pandas.py +105 -0
  356. chython/interop/test/test_rdkit.py +860 -0
  357. chython/interop/test/test_stereo.py +143 -0
  358. chython/interop/test/test_v2_oracle.py +480 -0
  359. chython/reactions/__init__.py +62 -0
  360. chython/reactions/_enumerate.py +447 -0
  361. chython/reactions/_numbering.py +102 -0
  362. chython/reactions/_reconstruct.py +415 -0
  363. chython/reactions/_stickers.py +169 -0
  364. chython/reactions/_tables.py +513 -0
  365. chython/reactions/attention/__init__.py +137 -0
  366. chython/reactions/attention/_assign.py +102 -0
  367. chython/reactions/attention/_encode.py +176 -0
  368. chython/reactions/attention/_session.py +91 -0
  369. chython/reactions/tables/functional.tsv +310 -0
  370. chython/reactions/tables/protective.tsv +138 -0
  371. chython/reactions/tables/reactions.tsv +427 -0
  372. chython/reactions/tables/roles.tsv +93 -0
  373. chython/reactions/test/__init__.py +18 -0
  374. chython/reactions/test/_frozen_ids.py +776 -0
  375. chython/reactions/test/gen_corpus_glossary.py +146 -0
  376. chython/reactions/test/golden_subset.smi +32 -0
  377. chython/reactions/test/test_attention.py +380 -0
  378. chython/reactions/test/test_attention_assign.py +174 -0
  379. chython/reactions/test/test_attention_encode.py +223 -0
  380. chython/reactions/test/test_attention_isolation.py +212 -0
  381. chython/reactions/test/test_corpus_glossary.py +91 -0
  382. chython/reactions/test/test_dependency_direction.py +166 -0
  383. chython/reactions/test/test_enumerate.py +451 -0
  384. chython/reactions/test/test_functional.py +55 -0
  385. chython/reactions/test/test_id_stability.py +147 -0
  386. chython/reactions/test/test_numbering.py +107 -0
  387. chython/reactions/test/test_probes.py +90 -0
  388. chython/reactions/test/test_protective.py +560 -0
  389. chython/reactions/test/test_reconstruct.py +456 -0
  390. chython/reactions/test/test_roles.py +140 -0
  391. chython/reactions/test/test_stickers.py +221 -0
  392. chython/reactions/test/test_tables.py +421 -0
  393. chython/test/__init__.py +24 -0
  394. chython/test/test_code_hygiene.py +184 -0
  395. chython/test/test_container_methods.py +200 -0
  396. chython/test/test_doc_figures.py +196 -0
  397. chython/test/test_doc_references.py +163 -0
  398. chython/test/test_doc_samples.py +256 -0
  399. chython/test/test_facade_names.py +198 -0
  400. chython/test/test_hydrogen_parity.py +315 -0
  401. chython/test/test_libinchi_staging.py +253 -0
  402. chython/test/test_log_records.py +84 -0
  403. chython/test/test_optional_numpy.py +239 -0
  404. chython/test/test_packaging.py +271 -0
  405. chython/test/test_performance.py +515 -0
  406. chython/test/test_r_atom_integration.py +88 -0
  407. chython/test/test_release_build.py +210 -0
  408. chython/test/test_stereo_bluebook.py +713 -0
  409. chython/test/test_v2_boundary.py +358 -0
  410. chython-3.0.dist-info/METADATA +203 -0
  411. chython-3.0.dist-info/RECORD +414 -0
  412. chython-3.0.dist-info/WHEEL +5 -0
  413. chython-3.0.dist-info/licenses/LICENSE +165 -0
  414. chython-3.0.dist-info/top_level.txt +1 -0
chython/__init__.py ADDED
@@ -0,0 +1,68 @@
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+ # Copyright 2014-2026 Ramil Nugmanov <nougmanoff@protonmail.com>
4
+ # Copyright 2014-2019 Timur Madzhidov tmadzhidov@gmail.com features and API discussion
5
+ # Copyright 2014-2019 Alexandre Varnek <varnek@unistra.fr> base idea of CGR approach
6
+ # This file is part of chython.
7
+ #
8
+ # chython is free software; you can redistribute it and/or modify
9
+ # it under the terms of the GNU Lesser General Public License as published by
10
+ # the Free Software Foundation; either version 3 of the License, or
11
+ # (at your option) any later version.
12
+ #
13
+ # This program is distributed in the hope that it will be useful,
14
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
15
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16
+ # GNU Lesser General Public License for more details.
17
+ #
18
+ # You should have received a copy of the GNU Lesser General Public License
19
+ # along with this program; if not, see <https://www.gnu.org/licenses/>.
20
+ #
21
+ """chython's public surface: a facade re-exporting the packages below it.
22
+
23
+ `__all__` is empty by design. `smarts` is the short spelling of `read_smarts` and the same function
24
+ object. `smiles` is bidirectional: a string in reads -- a `>` that is not a dative `->` makes it a
25
+ reaction SMILES and the result a `ReactionContainer` -- and a container in writes one. `pach` is the
26
+ same door for the wire format, and `unpach`/`unpack` its import half under chython 2's two names.
27
+ """
28
+ from sys import modules as _modules
29
+ from types import ModuleType as _ModuleType
30
+ from .core import *
31
+ from .core import read_smarts as smarts
32
+ from .depict import (Clean2DEngine, DepictStyle, get_clean2d_engine, get_depict_style,
33
+ set_clean2d_engine, set_depict_style)
34
+ from .formats import *
35
+ # By full path, not through `formats`' star: `pdb` is that subpackage's name too and the function
36
+ # would shadow it.
37
+ from .formats.pdb import PDBAtom, PDBBond, PDBRecord, build_molecule, mmcif, pdb, read_mmcif, read_pdb
38
+ # Imported for its registration side effect as much as for its names: it calls `_set_standardize_fn`
39
+ # at import time, which is what makes `mol.standardize()` exist. Not an unused import.
40
+ from .chemistry import *
41
+ # Likewise: `_set_reactions_fns` at import time is what makes `mol.react()` and `mol @ other` exist.
42
+ from .reactions import *
43
+ from .interop import iupac, patch_pandas
44
+ from .interop.config import _facade_alias as _interop_facade_alias
45
+
46
+
47
+ class _Facade(_ModuleType):
48
+ """Gives `chython` itself a property, so `chython.clean2d_engine` forwards both the read and the
49
+ write to its one home in `depict/_config.py` and the setter validates the name on the spot.
50
+ """
51
+ @property
52
+ def clean2d_engine(self) -> Clean2DEngine:
53
+ return get_clean2d_engine()
54
+
55
+ @clean2d_engine.setter
56
+ def clean2d_engine(self, engine: Clean2DEngine):
57
+ set_clean2d_engine(engine)
58
+
59
+
60
+ _modules[__name__].__class__ = _Facade
61
+
62
+ # `conformer_engine` and `class_paths` live in `chython.interop.config`; aliased here rather than
63
+ # copied, or `chython.conformer_engine = 'cdpkit'` would be a silent no-op. Must run AFTER the
64
+ # `__class__` assignment above: `_facade_alias` subclasses whatever class the module currently has, so
65
+ # the reverse order would replace the aliasing subclass and turn both names into AttributeErrors.
66
+ _interop_facade_alias(__name__, 'conformer_engine', 'class_paths')
67
+
68
+ __all__ = []
chython/_functions.py ADDED
@@ -0,0 +1,113 @@
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 functools import wraps
20
+ from itertools import product
21
+ from warnings import warn
22
+
23
+
24
+ _SENTINEL = object()
25
+
26
+
27
+ def renamed_name(old, new):
28
+ """Announce a superseded attribute spelling, naming what replaced it.
29
+
30
+ ONE FUNCTION SO THE MESSAGE HAS ONE WORDING. Every alias routes through here, so the text a
31
+ consumer greps for while porting is the same text in all of them, and the removal is one edit.
32
+
33
+ `stacklevel=3` charges the warning to the CALLER, which is the only person who can act on it: the
34
+ three frames are `warn` -> this function -> the property's fget or fset -> the consumer's line.
35
+ THE NUMBER IS MEASURED, NOT REASONED. Its counterpart in the compiled core is 1 for the same
36
+ intent, because neither a `cdef` helper nor a compiled `def` pushes a Python frame; so the right
37
+ number is a property of the call chain rather than of the source, and both are asserted by a test
38
+ on the blamed line rather than trusted.
39
+ """
40
+ warn(f'`{old}` was renamed to `{new}` and will be removed in a later release; use `{new}`',
41
+ DeprecationWarning, stacklevel=3)
42
+
43
+
44
+ def cached_method(func):
45
+ """Cache no-argument method result in instance __dict__. Cleared by flush_cache().
46
+
47
+ Thread-safe for concurrent reads without locking:
48
+ - dict.get/setitem are atomic in CPython 3.14 free-threaded mode
49
+ - Wrapped functions are pure (deterministic, read-only on self)
50
+ - Duplicate computation on cold cache is benign (same result)
51
+ - Mutations must be sequential (caller's responsibility)
52
+ """
53
+ key = f'__cached_method_{func.__name__}'
54
+
55
+ @wraps(func)
56
+ def wrapper(self):
57
+ val = self.__dict__.get(key, _SENTINEL)
58
+ if val is not _SENTINEL:
59
+ return val
60
+ val = func(self)
61
+ self.__dict__[key] = val
62
+ return val
63
+ return wrapper
64
+
65
+
66
+ # lazy itertools.product with diagonal combination precedence
67
+ def lazy_product(*args):
68
+ if len(args) == 1:
69
+ for x in args[0]:
70
+ yield x,
71
+ elif not args:
72
+ yield ()
73
+ else:
74
+ gens = [iter(x) for x in args]
75
+ empty = [False] * len(args)
76
+ pools = [[] for _ in range(len(args))]
77
+ indices = set()
78
+
79
+ reached = 0
80
+ while True:
81
+ out = []
82
+ ind = []
83
+ for n, (p, g, e) in enumerate(zip(pools, gens, empty)):
84
+ if e:
85
+ out.append(p[-1])
86
+ else:
87
+ try:
88
+ x = next(g)
89
+ except StopIteration:
90
+ if not p: # one of gens empty
91
+ return
92
+ reached += 1
93
+ if reached == len(args):
94
+ break
95
+ out.append(p[-1])
96
+ empty[n] = True
97
+ else:
98
+ p.append(x)
99
+ out.append(x)
100
+ ind.append(len(p) - 1)
101
+ else:
102
+ yield tuple(out)
103
+ indices.add(tuple(ind))
104
+ continue
105
+ break
106
+
107
+ for ind in product(*(range(len(p)) for p in pools)):
108
+ if ind in indices:
109
+ continue
110
+ yield tuple(p[x] for x, p in zip(ind, pools))
111
+
112
+
113
+ __all__ = ['cached_method', 'lazy_product', 'renamed_name']
@@ -0,0 +1,86 @@
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+ # Copyright 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
+ """Chemical knowledge as TSV in `tables/`, and the passes that apply it.
20
+
21
+ Imports `chython.core` and the standard library only, never the `chython` facade. Importing it registers
22
+ `standardize()`, `canonicalize()` and friends on the core container by injection, `MoleculeContainer`
23
+ being a `cdef class`. No pass runs on parse.
24
+
25
+ `perceive_bonds`, `saturate` and `expand_abbreviations` are the passes with no method. The first two
26
+ are the two halves of building a molecule out of a coordinate file -- which pairs are bonded, then at
27
+ what order -- so their caller is whoever read that file, and neither runs on read. The third reads what
28
+ a drawing wrote on an atom, which is a fact about a FILE and not about a structure, so it belongs beside
29
+ the reader that stored the alias rather than on every molecule.
30
+ """
31
+ from ._abbreviations import expand_abbreviations
32
+ from ._canonicalize import canonicalize
33
+ from ._counts import (hydrogen_bond_acceptors_count, hydrogen_bond_donors_count,
34
+ rotatable_bonds_count)
35
+ from ._crippen import crippen_logp, crippen_mr
36
+ from ._hydrogens import explicify_hydrogens, implicify_hydrogens
37
+ from ._implicit import calc_implicit, check_valence
38
+ from ._isomers import standardize_isomers
39
+ from ._maccs import maccs_bit_set, maccs_keys
40
+ from ._perceive import perceive_bonds
41
+ from ._pharmacophore import pharmacophore_invariants
42
+ from ._protomers import neutralize
43
+ from ._qed import alert_count, qed, qed_properties
44
+ from ._residues import (RESIDUE_KINDS, ResidueTemplate, normalize_atom_name, residue_template,
45
+ residue_templates)
46
+ from ._resonance import fix_resonance
47
+ from ._salts import SaltComposition, decompose_salts, split_salts
48
+ from ._saturate import saturate
49
+ from ._smarts import SmartsSyntaxError, compile_smarts
50
+ from ._standardize import LogRecord, standardize
51
+ from ._tables import (ACID_ROLES, AbbreviationRow, AcidRow, Endpoint, RESONANCE_ROLES, Rule, SALT_ROLES,
52
+ SaltRow, abbreviation_row, abbreviations_rows, acids_rules, acids_rules_by_role,
53
+ acids_table_text, groups_rules,
54
+ metals_rules, read_table, resonance_rules, resonance_rules_by_role,
55
+ resonance_table_text, salts_rows, salts_rows_by_role, salts_species_keys,
56
+ salts_table_text, standardize_rules)
57
+ from ._tpsa import tpsa
58
+ from ..core._core import (_set_canonicalize_fn, _set_featurizer_fns, _set_hydrogens_fns,
59
+ _set_isomers_fn, _set_protomers_fn, _set_resonance_fn, _set_salts_fns,
60
+ _set_standardize_fn, _set_valence_fn)
61
+
62
+
63
+ __all__ = ['ACID_ROLES', 'AbbreviationRow', 'LogRecord', 'SALT_ROLES', 'SaltComposition',
64
+ 'abbreviation_row', 'abbreviations_rows', 'alert_count',
65
+ 'calc_implicit', 'canonicalize', 'check_valence', 'crippen_logp', 'crippen_mr',
66
+ 'decompose_salts', 'expand_abbreviations', 'explicify_hydrogens', 'fix_resonance',
67
+ 'hydrogen_bond_acceptors_count',
68
+ 'hydrogen_bond_donors_count', 'implicify_hydrogens', 'maccs_bit_set', 'maccs_keys',
69
+ 'neutralize', 'perceive_bonds', 'pharmacophore_invariants', 'qed', 'qed_properties',
70
+ 'rotatable_bonds_count',
71
+ 'saturate', 'split_salts', 'standardize', 'standardize_isomers', 'tpsa']
72
+
73
+ _set_standardize_fn(standardize)
74
+ _set_canonicalize_fn(canonicalize)
75
+ _set_hydrogens_fns(implicify_hydrogens, explicify_hydrogens)
76
+ _set_isomers_fn(standardize_isomers)
77
+ _set_valence_fn(check_valence)
78
+ _set_salts_fns(split_salts=split_salts, decompose_salts=decompose_salts)
79
+ _set_protomers_fn(neutralize)
80
+ _set_resonance_fn(fix_resonance)
81
+ _set_featurizer_fns(rotatable_bonds_count=rotatable_bonds_count,
82
+ hydrogen_bond_donors_count=hydrogen_bond_donors_count,
83
+ hydrogen_bond_acceptors_count=hydrogen_bond_acceptors_count,
84
+ tpsa=tpsa, crippen_logp=crippen_logp, crippen_mr=crippen_mr, qed=qed,
85
+ maccs_keys=maccs_keys, maccs_bit_set=maccs_bit_set,
86
+ pharmacophore_invariants=pharmacophore_invariants)
@@ -0,0 +1,127 @@
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+ # Copyright 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
+ """Expansion of a contracted group drawn as one labelled atom.
20
+
21
+ A file that draws one atom and writes `OMe` on it has stated a methoxy group. The reader stores the
22
+ label as the atom's alias and the atom as an R -- neither invents a structure -- and this pass turns the
23
+ ones `tables/abbreviations.tsv` knows into atoms. A label the table does not know is left alone with its
24
+ alias, which is what makes the table safe to grow.
25
+
26
+ THE LABELLED ATOM IS TRANSMUTED, NOT REPLACED. `set_element` keeps its stable id, its bonds and its
27
+ neighbours' parities; deleting it and adding the fragment's attachment atom instead would reorder a
28
+ neighbouring stereocentre's references and invalidate a parity nothing here restated.
29
+ """
30
+ from ._tables import abbreviation_row
31
+ from ..core import H_UNKNOWN, LogRecord, MoleculeContainer, REFUSED, REPAIRED, recording
32
+
33
+
34
+ __all__ = ['expand_abbreviations']
35
+
36
+
37
+ #: Neither exactly one neighbour nor a single bond to it: the marker in the table states the one single
38
+ #: bond a contracted group hangs by, and a site that is not that is not this group.
39
+ _RULE_ATTACHMENT = 'abbreviations:attachment'
40
+
41
+ #: The file stated a charge, a radical or an isotope on the labelled atom, and the label states one too.
42
+ #: Nothing here ranks the two, so the site keeps the label.
43
+ _RULE_STATED = 'abbreviations:stated-atom'
44
+
45
+
46
+ def expand_abbreviations(molecule: MoleculeContainer) -> bool:
47
+ """Replace every atom whose alias names a row of `tables/abbreviations.tsv` with that fragment.
48
+
49
+ All-or-nothing per site. A site that survives every check is expanded and its alias dropped; a site
50
+ that fails one is left exactly as the file drew it, alias included, and the reason is a REFUSED
51
+ record. Returns True when at least one site was expanded.
52
+
53
+ | Outcome | Rule | Severity |
54
+ | --- | --- | --- |
55
+ | expanded | the row's own id, `abbreviations:OMe` | REPAIRED |
56
+ | not one single bond to one neighbour | `abbreviations:attachment` | REFUSED |
57
+ | charge, radical or isotope stated on the labelled atom | `abbreviations:stated-atom` | REFUSED |
58
+
59
+ The grafted atoms take the labelled atom's coordinates, so a record with a depiction needs
60
+ `clean2d()` afterwards; the message says so where there was one to disturb.
61
+ """
62
+ with recording(molecule, stage='abbreviations') as log:
63
+ aliases = molecule.aliases
64
+ if not aliases:
65
+ return False
66
+
67
+ sites = []
68
+ for n, text in aliases.items():
69
+ try:
70
+ label = text.decode('utf-8')
71
+ except UnicodeDecodeError: # not a spelling any table holds
72
+ continue
73
+ row = abbreviation_row(label)
74
+ if row is None:
75
+ continue
76
+
77
+ neighbors = list(molecule.neighbors_of(n))
78
+ if len(neighbors) != 1 or molecule.order_of(n, neighbors[0]) != 1:
79
+ log.append(LogRecord(_RULE_ATTACHMENT, (n,),
80
+ f'atom {n}: {label} hangs by one single bond and this atom has '
81
+ f'{len(neighbors)} neighbours; the label is kept', REFUSED))
82
+ continue
83
+ atom = molecule.atom(n)
84
+ if atom.charge or atom.is_radical or atom.isotope:
85
+ log.append(LogRecord(_RULE_STATED, (n,),
86
+ f'atom {n}: the record states charge {atom.charge}, radical '
87
+ f'{atom.is_radical} and isotope {atom.isotope} here, and {label} '
88
+ f'states its own; the label is kept', REFUSED))
89
+ continue
90
+ sites.append((n, label, row, atom.r_index, molecule.xy_of(n)))
91
+
92
+ if not sites:
93
+ return False
94
+
95
+ with molecule.edit():
96
+ for n, _, row, r_index, xy in sites:
97
+ fragment = row.fragment
98
+ anchor = fragment.atom(row.attachment)
99
+ if r_index: # an R index is only settable on element 0
100
+ molecule.set_r_index(n, 0)
101
+ molecule.set_element(n, anchor.element)
102
+ molecule.set_charge(n, anchor.charge)
103
+ molecule.set_radical(n, anchor.is_radical)
104
+ molecule.set_isotope(n, anchor.isotope)
105
+ molecule.set_hydrogens(n, H_UNKNOWN if anchor.implicit_h is None else anchor.implicit_h)
106
+
107
+ grafted = {row.attachment: n}
108
+ for a in fragment.atoms():
109
+ if a.n == row.marker or a.n == row.attachment:
110
+ continue
111
+ grafted[a.n] = molecule.add_atom(a.element, charge=a.charge, isotope=a.isotope,
112
+ radical=a.is_radical, implicit_h=a.implicit_h)
113
+ if xy is not None:
114
+ molecule.set_xy(grafted[a.n], xy[0], xy[1])
115
+ for bond in fragment.bonds():
116
+ if bond.n == row.marker or bond.m == row.marker:
117
+ continue
118
+ molecule.add_bond(grafted[bond.n], grafted[bond.m], bond.order)
119
+
120
+ molecule.set_aliases({n: text for n, text in aliases.items()
121
+ if n not in {n for n, _, _, _, _ in sites}})
122
+ for n, label, row, _, xy in sites:
123
+ drawn = ', and the grafted atoms share its coordinates, so the record needs a 2D clean' \
124
+ if xy is not None else ''
125
+ log.append(LogRecord(row.id, (n,), f'atom {n}: the label {label} named a contracted group '
126
+ f'and was expanded to {row.smiles}{drawn}', REPAIRED))
127
+ return True
@@ -0,0 +1,152 @@
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+ # Copyright 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
+ """`canonicalize()` -- the pre-pass that makes `canonical_bytes` a compound identity rather than a
20
+ drawing identity, so equal compounds hash equal and a corpus deduplicates by hash. The stage order
21
+ is a correctness constraint, not taste: `kekule()`, `standardize()`, `implicify_hydrogens()`,
22
+ `neutralize()`, `thiele()`, `standardize_isomers()`, and `kekule()` again only under
23
+ `keep_kekule=True`. Each ordering pair is justified at its numbered step below.
24
+
25
+ The order alone is not enough, because the last stage can unblock the second one: a `tautomer` row
26
+ wanting a free ring nitrogen cannot fire while the mobile hydrogen sits on it, and the placement is
27
+ what moves that hydrogen off. So steps 2 to 6 run to a fixed point rather than once -- step 7.
28
+ """
29
+ from ._hydrogens import implicify_hydrogens
30
+ from ._isomers import standardize_isomers
31
+ from ._protomers import neutralize
32
+ from ._standardize import standardize
33
+ from ..core import LOST, LogRecord, MoleculeContainer, recording
34
+
35
+
36
+ __all__ = ['canonicalize']
37
+
38
+
39
+ _RULE_ROUNDS = 'canonicalize:rounds'
40
+
41
+ #: How many times steps 2 to 6 may be re-run before the pipeline gives up and says so. Every shape
42
+ #: measured converges in two, and the loop cannot cycle in principle: the `tautomer` rows move a
43
+ #: hydrogen from oxygen or sulfur to nitrogen and never back, and the placement stage never makes an
44
+ #: oxygen or a sulfur a site. The cap is here so a rule table that breaks either half is reported as
45
+ #: a loss rather than hanging.
46
+ _ROUNDS_MAX = 5
47
+
48
+
49
+ def canonicalize(molecule: MoleculeContainer, *, fix_tautomers: bool = True,
50
+ keep_kekule: bool = False) -> bool:
51
+ """Bring `molecule` to the representation two drawings of one compound share. Did it change?
52
+
53
+ Run before deduplicating by `canonical_bytes`, `__hash__` or `__eq__`, which otherwise answer "same
54
+ drawing" rather than "same compound".
55
+
56
+ `molecule.log` gets one record per thing done or declined, tagged with the stage that wrote it, so
57
+ `mol.log.by_stage('standardize')`, `mol.log.repaired()` and `mol.log.lost()` all answer afterwards.
58
+ `fix_tautomers` is forwarded to `standardize()`, and switching it off gives up part of the guarantee
59
+ (`Oc1ccccn1` and `O=c1cccc[nH]1` stop hashing equal); it deliberately does not reach
60
+ `standardize_isomers()`, which picks between two valid annular forms rather than repairing one.
61
+ `keep_kekule=True` costs a second kekulisation rather than a skipped `thiele()`, the placement stage
62
+ needing the aromatic form.
63
+
64
+ CHARGES ARE PAIRED OFF, not preserved atom by atom: glycine's zwitterion and its neutral drawing
65
+ share a key, because step 4 runs `neutralize()`. The NET charge is untouched, so sodium acetate
66
+ stays sodium acetate -- there is no proton in it to move -- while ammonium acetate becomes acetic
67
+ acid and ammonia, both drawings of one salt.
68
+ """
69
+ # A refusal at the answer boundary, which is the only place one belongs. `smiles()` returns
70
+ # whichever container its string describes, so a `>>` in a structure column arrives here as a
71
+ # reaction; without this the first read of `canonical_bytes` fails with an `AttributeError` naming
72
+ # a private attribute, which tells a caller nothing about what the pass takes.
73
+ if not isinstance(molecule, MoleculeContainer):
74
+ raise TypeError(f'canonicalize() takes a MoleculeContainer, got '
75
+ f'{type(molecule).__name__}; a ReactionContainer has its own canonicalize(), '
76
+ f'which runs this pass on each of its molecules')
77
+
78
+ # the bool is measured, not accumulated: steps 1 and 5 are a round trip, so summing the stages'
79
+ # own flags would report a change through both ends of a no-op on an already-canonical molecule.
80
+ before = molecule.canonical_bytes
81
+
82
+ # 1. Kekule first, so the group rules see definite bond orders. An aromatic system with no Kekule
83
+ # form does not stop the pipeline: it is reported and the rest still runs. `kekule()` also
84
+ # heals the hydrogen counts its own orders made derivable, so nothing here does that -- the
85
+ # pipeline is literally the manual steps.
86
+ # Nothing is recorded here about `result.unresolved`: the kekuliser writes a `LOST` record per
87
+ # system to `molecule.log` itself, naming it in `atoms`, so a second one here would summarise an
88
+ # event already reported. `check_valence()` is still how those atoms are found.
89
+ molecule.kekule()
90
+
91
+ # 2. Repair the drawing.
92
+ standardize(molecule, fix_tautomers=fix_tautomers)
93
+
94
+ # 3. Explicit hydrogens are a `canonical_bytes` difference, so they have to go.
95
+ implicify_hydrogens(molecule)
96
+
97
+ # 4. Pair off the charges an acid/base row can pair off, so a zwitterion and its neutral drawing
98
+ # hash equal. AFTER step 3, because `acids.tsv` reads implicit hydrogens: a cation drawn with
99
+ # hydrogen ATOMS is invisible to it until they have been folded in. `keep_charge` stays at its
100
+ # default -- the net charge is part of the compound, so a canonical form may move a proton but
101
+ # never create or destroy one. A quaternary ammonium keeps its counterion: it has no proton to
102
+ # give, so the pass finds no donor and declines.
103
+ neutralize(molecule)
104
+
105
+ # 5. Back to the aromatic form, which is the representation callers compare. Ahead of step 6,
106
+ # because a mobile hydrogen is a property of the aromatic form: step 1's definite orders already
107
+ # say where the hydrogen is, leaving the placement stage nothing to choose.
108
+ # `result.refused` is not recorded on top of the pass's own records either, and for the same
109
+ # reason -- with the severity the aromatiser itself states, which is `REFUSED` and not a loss.
110
+ molecule.thiele()
111
+
112
+ # 6. Canonical placement of mobile hydrogens and charges -- what makes the two N-H forms of
113
+ # 4-methylimidazole hash equal. Not gated by `fix_tautomers`: that flag withholds local repair
114
+ # rules, and this picks which of two valid drawings to keep rather than repairing one.
115
+ moved = standardize_isomers(molecule)
116
+
117
+ # 7. Steps 2 to 6 again, while the placement keeps unblocking a repair. `Oc1[nH]cnc2nncc1-2` is
118
+ # the shape: its mobile hydrogen sits on the one ring nitrogen the hydroxy-azine rows need free,
119
+ # so step 2 declines, and by the time step 6 has moved it the repair is behind us -- the drawing
120
+ # kept its hydroxy form and the same compound drawn the other way got the oxo form and a
121
+ # different key. Only the placement is re-entered from, since it is the one stage that can put
122
+ # the molecule back into a shape an earlier stage would have acted on.
123
+ #
124
+ # `kekule()` leads, and not for the reason step 1 does: the `tautomer` rows are written against
125
+ # definite bond orders, so on the aromatic form step 5 left behind they match nothing at all and
126
+ # re-running step 2 would be a guaranteed no-op. Step 4 is re-entered only behind a repair,
127
+ # which is the only thing that can hand it a charged site it has not already seen.
128
+ for _ in range(_ROUNDS_MAX):
129
+ if not moved:
130
+ break
131
+ molecule.kekule()
132
+ changed = standardize(molecule, fix_tautomers=fix_tautomers)
133
+ if changed:
134
+ implicify_hydrogens(molecule)
135
+ neutralize(molecule)
136
+ molecule.thiele() # unconditional: step 5's form is what a caller compares, and
137
+ if not changed: # the kekulisation above has to be undone either way
138
+ break
139
+ moved = standardize_isomers(molecule)
140
+ else:
141
+ with recording(molecule, stage='canonicalize') as log:
142
+ log.append(LogRecord(_RULE_ROUNDS, (), f'repair and placement were still changing the '
143
+ f'molecule after {_ROUNDS_MAX} rounds; the pipeline stopped there, so '
144
+ f'this molecule is not a fixed point and two drawings of it may not '
145
+ f'share a key', LOST))
146
+
147
+ # 8. `keep_kekule` undoes step 5 rather than skipping it: skipping 5 would skip 6 with it, and the
148
+ # flag would then decide which tautomer the caller gets.
149
+ if keep_kekule:
150
+ molecule.kekule()
151
+
152
+ return molecule.canonical_bytes != before
@@ -0,0 +1,81 @@
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+ # Copyright 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
+ """Rotatable bond and H-bond donor/acceptor counts, over `tables/rotatable.tsv` and `tables/hbond.tsv`.
20
+ """
21
+ from ._standardize import LogRecord
22
+ from ..core import recording
23
+ from ._tables import hbond_rules_by_role, rotatable_rules_by_role
24
+
25
+
26
+ def _mapped_bond(row, mapping):
27
+ """The (low, high) stable-id pair the row's :1 and :2 matched, order-normalised."""
28
+ a = mapping[row.numbers[1]]
29
+ b = mapping[row.numbers[2]]
30
+ return (a, b) if a < b else (b, a)
31
+
32
+
33
+ def rotatable_bonds_count(molecule) -> int:
34
+ """Number of rotatable bonds, by the definition in `tables/rotatable.tsv`.
35
+
36
+ A bond is counted once however many ways a pattern maps onto it: row 1 is symmetric in its two
37
+ atoms and the matcher offers each bond in both directions, so the set is what makes this a count of
38
+ bonds rather than of matches. Charged and radical atoms count.
39
+ """
40
+ by_role = rotatable_rules_by_role()
41
+ found = set()
42
+ excluded = []
43
+ for row in by_role['rotatable']:
44
+ for mapping in row.query.get_mapping(molecule):
45
+ found.add(_mapped_bond(row, mapping))
46
+ for row in by_role['exclude']:
47
+ for mapping in row.query.get_mapping(molecule):
48
+ bond = _mapped_bond(row, mapping)
49
+ if bond in found:
50
+ found.discard(bond)
51
+ excluded.append(LogRecord(row.id, bond, row.description))
52
+ with recording(molecule, stage='rotatable') as log:
53
+ log.extend(excluded)
54
+ return len(found)
55
+
56
+
57
+ def hbond_atoms(molecule, role: str) -> frozenset:
58
+ """Stable ids of the atoms `tables/hbond.tsv` types with `role`.
59
+
60
+ Shared by the two counts and by `pharmacophore_invariants`, which is why it returns ids rather
61
+ than a number.
62
+ """
63
+ rules = hbond_rules_by_role()
64
+ if role not in rules:
65
+ raise ValueError(f'role {role!r} is not one of {tuple(rules)}')
66
+ out = set()
67
+ for row in rules[role]:
68
+ subject = row.numbers[1] # the stable id of :1, from compile_smarts at load time
69
+ for mapping in row.query.get_mapping(molecule):
70
+ out.add(mapping[subject])
71
+ return frozenset(out)
72
+
73
+
74
+ def hydrogen_bond_donors_count(molecule) -> int:
75
+ """Count of hydrogen bond donor ATOMS, over `tables/hbond.tsv`."""
76
+ return len(hbond_atoms(molecule, 'donor'))
77
+
78
+
79
+ def hydrogen_bond_acceptors_count(molecule) -> int:
80
+ """Count of hydrogen bond acceptor ATOMS, over `tables/hbond.tsv`."""
81
+ return len(hbond_atoms(molecule, 'acceptor'))