Repository navigation
Releases: chython/chython
Release list
3.4
A feature release for linker chemistry and reaction reconstruction. It adds carboxyl-side ester protecting groups, azole N-caps, ureas and thioureas, and N-H nucleophile coverage across the coupling rows. Stripped esters are labelled by mechanism, masking works on symmetric cores, and pyridone and azaindole tautomers are placed the same way whatever the atom order. Five things change for an existing caller; they are listed under Compatibility below.
The corpus grows. Functional groups go from 253 to 265, protective groups from 103 to 106, reaction rows from 294 under 72 names to 676 under 82, and role rows from 87 to 89.
| added | rows |
|---|---|
| N-H nucleophile groups: carbamate, urea, sulfonamide, activated (N-O) ester, ring enamine NH2, alkenyl halide and sulfonate | functional:254–265 |
| N-alkylation, Buchwald–Hartwig, SNAr, Mitsunobu, amidation, sulfonylation, N-acylation and Chan–Lam over the N-H set; Weinreb amidation, acid alkylation, alcohol amination, Appel iodide | reactions up to 676 |
urea_from_amines over every pair of the five N-nucleophiles, and thiourea_from_amines |
reactions:295–321 |
carboxyl_tbu, carboxyl_benzyl, carboxyl_allyl |
protective:104–106 |
azole_nitrogen, capping the h0 nitrogen of an NH-pyrazole and an NH-imidazole |
roles:88–89 |
Organozinc groups also match [Zn;*], so a zinc reagent drawn without its halide still matches.
>>> r = smiles('NCc1ccccc1.Nc1ccccc1>>O=C(NCc1ccccc1)Nc1ccccc1')
>>> r.reconstruct_mapping()
('react:urea_from_amines',)An ester is stripped from its alkyl half and labelled ester_hydrolysis. When both halves of an ester match a protective row, the alkyl half claims it, so the ester reads as a protected acid. A strip that cuts a carboxylic ester, on either side, is labelled by its mechanism. Carbonates, carbamates and ethers keep their row name. EnumeratedDeprotection gains a fourth field, labels, parallel to names:
>>> m = smiles('c1ccccc1C(=O)OC'); m.canonicalize()
>>> o = next(m.deprotect()); o.names, o.labels
(('hydroxyl_methyl',), ('ester_hydrolysis',))
>>> m = smiles('c1ccccc1C(=O)OC(C)(C)C'); m.canonicalize()
>>> next(m.deprotect()).names
('carboxyl_tbu',)reconstruct_mapping() builds its labels from these. deprotect+react:<row> becomes deprotect:<labels>+react:<row>, so the label says what was stripped. protect: keeps the row names. A lone ester cleaved to its acid stays react:ester_hydrolysis.
>>> smiles('CC(C)(C)OC(=O)NCCC(=O)OC.OC(=O)c1ccccc1>>O=C(O)CCNC(=O)c1ccccc1').reconstruct_mapping()
('deprotect:amine_boc+ester_hydrolysis+react:amidation',)Reconstruction is harder to fool.
- Outcomes are canonicalized before they are compared.
- A component the outcome carried through unchanged from its inputs is not evidence: a counterion in a salt product no longer confirms a row.
- The protect rung settles the strip against the inputs, which recovers azole THP, SEM and Boc protections left at
H_UNKNOWN. - A rewritten centre keeps its isotope unless the token states one.
An unmapped branch is part of the reaction centre. reaction_center compares the canonical SMILES of each unmapped branch on a mapped atom. An ester hydrolysis whose leaving and incoming oxygens are both unnumbered, which is the shape reconstruct_mapping() writes, now has a centre, and remove_reagents() no longer raises on it.
A mask applies before equivalent cuts collapse. Masking one nitrogen of piperazine used to leave nothing, because the collapse had kept the masked cut as the representative. The mask now applies first, so the other end is cut. Unmasked enumeration is unchanged. StickyFragment.atom and StickyLinker.atom_left / atom_right name the source atom each cap hangs off.
>>> m = smiles('C1CNCCN1'); m.canonicalize()
>>> f, = m.sticky_fragments('alkyl_amine')
>>> [g.atom for g in m.sticky_fragments('alkyl_amine', masked={f.atom})]
[6]The template gains dedupe=False, which yields every embedding's outcome, including two that build one structure from different atoms:
>>> t = read_smirks('[C:1][OH]>>[C:1]Cl')
>>> len(list(t(smiles('OCCO')))), len(list(t(smiles('OCCO'), dedupe=False)))
(1, 2)A lactam's N-H sits next to its carbonyl, whatever the atom order. standardize_isomers() ranks candidate placements in this order:
- the aromatic bond count
thiele()gives, most first; - N-H next to a C=O or C=S carbon;
- N-H on a five-membered ring;
- atom ranks.
Terms 1–3 are graph facts, so they do not depend on how the molecule was drawn. canonicalize() also fixes the C=N of an aromatic lactim in place when the Kekulé form it was handed left the hydroxy-azine repair nothing to match, so 3-hydroxyisoquinoline reaches its lactam from every atom order:
>>> for s in ('Cc1cc(O)ncn1', 'Cc1cc(=O)[nH]cn1'):
... m = smiles(s); m.canonicalize(); print(m)
O=C1NC=NC(=C1)C
O=C1NC=NC(=C1)C
>>> m = smiles('c1c[nH]c2nccc2c1'); m.canonicalize(); print(m) # 7-azaindole reads 1H
[nH]1ccc2cccnc12A lactam N-H also no longer moves onto a fused pyridine N as a quinoid imine. An azole-fused pyridine N-oxide reaches its fixed point in one canonicalize() call, because neutralize() re-runs every round.
A drawn hydrogen is a hydrogen. In SMARTS, D counts heavy-atom neighbours only, and h counts every attached hydrogen, implicit or drawn, of any isotope. [2H]C([2H])(C)O is therefore a primary_alcohol, exactly as CCO is. neutralize() refuses, and logs, a proton drawn as an atom rather than overflowing the implicit count.
Core. A patch that bonds an aromatic pnictogen recomputes every other pnictogen with hydrogens in that ring, so the replaced tautomer's N-H comes back as H_UNKNOWN for kekule(). Tetrazoles and 1,2,3-triazoles arylated at the h0 nitrogen come out aromatic.
Compatibility
canonical_byteschanges for lactams whose N-H had been placed away from the carbonyl (guanine drawn as its 1,2-imino form, quinazolinones, pyrimidinones), for 7-azaindoles and other azole/azine pairs that read the azine N-H, and for lactimscanonicalize()used to leave in their hydroxy form. Re-key a stored corpus.- The
deprotect+react:labels change todeprotect:<labels>+react:<row>, and thedeprotect:labels readester_hydrolysisfor an ester strip. EnumeratedDeprotection,StickyFragmentandStickyLinkergain fields. Code that unpacks them positionally needs the new arity.- Ester claims move to the alkyl half. tert-Butyl and allyl benzoate, tert-butyl pivalate and tert-butyl trifluoroacetate are now
carboxyl_*; methyl benzoate ishydroxyl_methyl. - SMARTS
Dandhread drawn hydrogen atoms. A pattern matched against a molecule carrying explicit hydrogen atoms matches as it would afterimplicify_hydrogens().
3.3.2
3.3.1
3.3
A feature release. A writer writes the record and logs the field it could not carry, decompose_salts() reads a record as its components, enrich_from() merges two readings of one substance, a V2000 STEREOLABEL data group is read as an enhanced-stereo collection, and reconstruct_mapping() no longer loses a mapping over a configuration. Five things move for an existing caller, named under Compatibility below.
A writer writes the record and says what it could not carry. A refusal is answer-boundary behaviour and a serialiser is not an answer boundary: a loop over forty thousand records must not stop on a field one of them holds. Three classes decide it, by what a legal record would say and never by how much is lost:
| the case | the writer |
|---|---|
| no field for the data, and a legal record is a subset | writes, and logs one sentence naming the field |
| no legal record exists | raises, naming the fix |
| degraded geometry | normalizes, then writes |
>>> m = smiles('C[C@H](N)C(=O)O')
>>> m.set_stereo_group(2, STEREO_AND, 1)
>>> log = []
>>> record = mol(m, version=2000, log=log)
>>> [r.rule for r in log]
['wedge:no-coords-write', 'v2000:enhanced-stereo-not-written']pach's field refusals become log lines at stage pach — pach:cip-lost, pach:meta-lost, pach:sgroups-lost, each molecule's on that molecule's log and a reaction's meta and title on the reaction's:
>>> m = smiles('CCO')
>>> m.meta['id'] = '42'
>>> b = m.pack()
>>> [r.message for r in m.log]
['the pach format has no metadata field, so 1 key(s) were not written']strict=True restores the refusal for the caller whose pach record is the structure's only copy; drop= names a field at the door and logs nothing, the caller having already said it; and version=4 is the coordinate-free layout asked for by name and therefore a waiver of the drawing.
Class 2 is narrow by construction — no legal record exists, never this field would be lossy. A capacity a format can dodge by choosing a version is the writer's choice to make, so mol() answers 1200 atoms with a V3000 record and refuses only a pinned version=2000; what is left is a coordinate past the 10-character column and a side holding more than 255 molecules. Order 4 is written without comment: the spec lists it among the query bond types and implementations read it as a delocalised bond, so no CTAB writer refuses an aromatic bond or points at kekule().
chython/test/test_writer_posture.py is the tree-wide ratchet — thirteen writers over one molecule carrying every field some one of them has no room for — and it states the boundary too: smiles, inchi, inchikey and to_bytes are projections of a container rather than records of it, and inchi refusing an R marker is answer-boundary behaviour.
A record of several components is the normal case. A hydrate, an amine hydrochloride, a sodium carboxylate, a solvate a crystallization left behind, a reagent drawn beside the compound it acted on. Three surfaces read them, and none of them deletes a component:
| surface | does | returns |
|---|---|---|
split_salts() |
cuts the ionic bond, moving the charge onto the two ends | bool |
decompose_salts() |
reads the record as one featurized row per component | SaltComposition |
SaltComposition.compose() |
puts a chosen selection back together | MoleculeContainer |
decompose_salts() changes nothing and logs nothing — the return value is the whole report. Each row carries the normalized component, the species and class it matched, its role, the charge as drawn, the charge left after neutralization, the counts, and the atom ids in the caller's own drawing as a back-pointer:
>>> r = smiles('NCC(=O)O.Cl').decompose_salts()
>>> [str(row.molecule) for row in r.parents]
['C(CN)(=O)O']
>>> r.equivalents_by_species(), sorted(r.tags)
({'salts:hcl': 1}, ['acid_salt'])A stabilizer is eligible, never leftover. A row leaves only when its residual charge is zero, it is neither a lone metal nor organometallic, nothing puts it on counter-ion duty, and its class is one classes names — water, the mineral acids, the sulfonic acids and the C1–C2 carboxylic acids by default. So a quaternary ammonium keeps its halide, a metal salt keeps its metal, and a record holding nothing that is a compound in its own right answers all of its components rather than none. tags says what the record is — hydrate, solvate, metal_salt, ion_pair, charge_unbalanced, charges_undrawn, stabilizer_only and six more — where role says what this pass did with it.
compose() reassembles a selection over union(), so every parity and stereo group survives the round trip, and all four spellings of sodium acetate reach one parent key. chython/chemistry/tables/salts.tsv carries the knowledge: 127 rows, 110 of them a whole species across fourteen classes. match is how a row is recognized and klass is what it is; a pass selects by klass, so a new class is invisible to a pass that did not name it.
standardize() gains a fourth stage: the charges a salt was drawn without. A free metal drawn neutral takes its group number, and the balance is struck against the drawn anions by deprotonating the most acidic tabulated site:
>>> for s in ('CC(=O)O.[Na]', 'CC(=O)O.[Zn]'):
... m = smiles(s); m.standardize()
... print(m, [r.rule for r in m.log.refused()])
C(C)([O-])=O.[Na+] []
C(C)(=O)O.[Zn] ['salts:metal-charge']The refusal is the point: no ionic charge follows from zinc's electron count, so the record is left exactly as drawn and decompose_salts() tags it charges_undrawn. The stage writes charges and nothing else, and a covalent CC(=O)O[Na] stays covalent — a metal–oxygen bond is a representation choice split_salts() owns.
Two readings of one substance, each impoverished in a different way. A molfile has a plane and no way to spell an AND collection; a SMILES has the configuration and no plane. enrich_from(*others, stereo=, layout=) writes what a source states and this record does not into this molecule's own atoms — the atom count does not move and no component is added, which is what separates it from union():
>>> flat = smiles('CC(N)C(=O)O') # stand in for the drawing
>>> flat.clean2d()
>>> flat.enrich_from(smiles('C[C@H](N)C(=O)O'))
True
>>> flat.parity_of(2), [r.rule for r in flat.log]
(2, ['enrich:parity-borrowed'])The correspondence is isomorphism(), a stereo-blind canonical composition verified by the mapped bond set, so the two sides must be the same constitution down to the hydrogen counts, the kekule form and the protonation; one that is not takes nothing and gets one enrich:not-the-same-constitution line, the normalising being the caller's before the call.
| at a site both sides state | |
|---|---|
stereo='keep' |
this molecule's configuration stands; the disagreement is logged |
stereo='override' |
the source's configuration is written |
stereo='clear' |
the site is left unset — two files that disagree about a centre do not establish one |
layout='keep' |
the source's plane is taken only where this molecule has none |
layout='override' |
the source's plane is always taken |
A wedge is a statement about a drawing, so whenever the plane changes this molecule's wedges are dropped rather than carried onto a layout they were not drawn against. A collection is taken all-or-nothing and renumbered, two files numbering their &1 differently not having their racemates merged. Where several isomorphisms carry stereo differently and the orbits leave the choice open, the site is refused and logged enrich:symmetry-unresolved rather than guessed — per site, at stage enrich, so rates are log analysis and not a return shape.
A configuration word in the field the spec reserves for arbitrary data. V2000 has no syntax for an enhanced-stereo collection, its chiral flag being one bit for the whole record, and a large body of records states the partition as a DAT group named STEREOLABEL holding R, S, RS, *R or (S). Storing that where no query can reach it is not reading it:
>>> alanine = smiles('C[C@H](N)C(=O)O')
>>> alanine.clean2d()
>>> alanine.add_data_sgroup('StereoLabel', '*RS', atoms=[2])
SGroup(DAT, index=1, name='StereoLabel', data='*RS', atoms=[2])
>>> back = mol(mol(alanine, version=2000))
>>> back.stereo_labels()
[((2,), (), 'RS', True)]
>>> back.stereo_groups() # kind 3 is AND -- the label was promoted
{(3, 1): [2]}
>>> [r.rule for r in back.log]
['sgroup:stereo-label-promoted']The payload is normalised — parentheses stripped, letters upper-cased, a * on either side lifted out into relative so *R and CAS's R* are one word — and atoms is a tuple because one label covers one centre or a pair. The reader promotes one to a collection under two rules: a collection the record itself states wins, the spec-defined statement being the one to believe; and a promotion never invents a parity, so a label on an atom no configuration reaches is logged sgroup:stereo-label-not-promoted and left as the data record it is, as are E, Z and &, which name no collection. The marker reads as AND — it says the configuration is relative, this centre against the others and either enantiomer, where OR asserts that one of two named alternatives holds. An unmarked letter is the local descriptor and names no partition, so its ABS reaches only a record that states no collection, where a parity in no group already writes as absolute. Promotion consumes nothing, which is what lets a file that came in this way go back out and read the same. promote_stereo_labels() is the pass alone, for the record read under ignore_stereo or the labels built by hand.
A membership is not a configuration, which is what makes one direction safe and the other not. ...
3.2
A feature release. Enhanced stereo collections now hold axes as well as centres, assign_cip() computes R/S where CIP rules 1a, 1b and 2 decide, and standardize_kekule() picks the Kekule form thiele() reads. One thing moves for an existing caller, named under Compatibility below.
A collection holds axes as well as centres, in one id space. 3.1 could put a tetrahedral centre in an OR or AND collection; a double bond, an allene or an atropisomer axis could not be a member of one. 3.2 stores a collection at its unit's anchor slot, exactly as a parity is stored, so an axis carries a group by the same mechanism a centre does and there is no second plane to keep in step with the first.
| 3.1 | 3.2 | |
|---|---|---|
set_stereo_group(element, kind, group) |
an atom id | an atom id or an owner pair |
set_bond_stereo_group(n, m, kind, group) |
— | the pair spelling under its own name |
stereo_groups() members |
atom ids | an atom id, or an ascending owner pair |
bond_stereo_groups() |
— | the pair-spelled view of the same ids |
canonical_bond_stereo_groups() |
— | that view with canonical ids |
canonical_bond_stereo_group_ambiguities() |
— | the keys the colouring cannot separate |
has_bond_stereo_groups |
— | True when any collection has an axis member |
stereo_group_anchor_of(element) |
— | the atom an atom-only format names |
>>> m = smiles('F/C=C/C=C/F')
>>> m.set_bond_stereo_group(2, 3, 3, 1) # AND 1
>>> m.set_bond_stereo_group(4, 5, 3, 1) # the same collection
>>> m.stereo_groups()
{(3, 1): [(2, 3), (4, 5)]}One namespace, two spellings. A bond group numbered 1 and an atom group numbered 1 are one collection, so a centre and an axis can be members of the same OR or AND set. bond_stereo_groups() is a view rather than a second store: it shares the ids stereo_groups() reports, and canonical_bond_stereo_groups() is a view of canonical_stereo_groups().
An axis is named by its owners, not by the atom its byte lives at. An allene's anchor is the chain midpoint, which is neither owner, so the members are the two terminals:
>>> a = smiles('CC=C=CC')
>>> a.set_stereo_group(3, 3, 1) # stated on the chain midpoint
>>> a.stereo_groups()
{(3, 1): [(2, 4)]}
>>> a.stereo_group_anchor_of((2, 4))
3Forgiving in, canonical out: a group stated on a pair no unit owns is kept as a label on the lower atom and logged edit:stereo-group-not-an-axis, never refused.
Compatibility. A caller that assumed every member of stereo_groups() is an int must now handle a 2-tuple, since a two-owner unit reports its owner pair. bond_stereo_groups() returns the axes alone if that is all you want, and one-owner members are still plain atom ids. Nothing in the canonical form moves: 3.1's keys are 3.2's keys.
Every format spells the axis it can, and says so where it cannot.
| format | how an axis is named |
|---|---|
| V3000 | its chain midpoint, both directions — a bond collection where a bond joins the owners, an atom collection where none does |
| CXSMILES | an owner atom, chosen by canonical position so the string is a function of the canonical form |
| MRV | mrvStereoGroup on the anchor atom |
| pach v3/v4 | a five-byte entry keyed on the owner pair, emitted in anchor order so decode and re-encode is byte-identical |
| V2000 | one chiral bit per record; an AND or OR collection is reported v2000:enhanced-stereo-not-written |
M V30 MDLV30/STEBRAC1 BONDS=(2 2 4) the diene above
M V30 MDLV30/STERAC1 ATOMS=(1 3) the allene, named at its midpoint
>>> format(m, 'x')
'C(=C\\C=C\\F)/F |&1:0,2|'
>>> MoleculeContainer.unpack(m.pack()).bond_stereo_groups()
{(3, 1): [(2, 3), (4, 5)]}The pach v3 and v4 header field the earlier fifty stored records all leave zero is now read, and the two frozen corpora are byte-unchanged — the block is additive, not a layout change. docs/pach.rst carries the wire layout as executed samples.
CIP descriptors, where rules 1a, 1b and 2 decide. assign_cip() writes R and S over a hash-consed digraph ranked sphere by sphere, ordering by atomic number, a duplicate's distance from the root, and mass:
>>> c = smiles('C[C@H](O)F')
>>> c.assign_cip()
True
>>> c.atom_cips()
{2: 'R'}It recomputes: a stored descriptor is never read as a hint, a tie-break or a cache, and the computed answer overwrites what an input stated, under cip:disagreed. A tetrahedral site those three rules tie, and any site with an aromatic bond in reach, keeps no descriptor and gets one cip:undecided line — the rules that settle those sites are rule 3 and up, so atom_cips() answering {} for a configured centre is an answer and the log says which one. A site with no parity gets neither a descriptor nor a line. Bond descriptors remain stated-only.
A double bond an alternating cycle can move is a Kekule choice. thiele() reads the drawing it is handed, so a compound with more than one Kekule form had one aromatic form per form. standardize_kekule() chooses first — the most double bonds inside a ring thiele() considers, and for a ring outside the 5–7 window the alternation with the smaller canonical form:
>>> x = smiles('CC1=CC=CC=CC=C1C') # 1,2-dimethylcyclooctatetraene
>>> y = smiles('CC1=C(C)C=CC=CC=C1') # the same compound, C1=C2 written double
>>> x.standardize_kekule(), y.standardize_kekule()
(True, False)
>>> x == y
TrueEvery form it chooses between is a valid molecule, so its two choice records are INFO and a trial that had to move a hydrogen or a charge is a failed trial; kekule-form:budget is LOST, a molecule that reaches the 512-kekulisation budget keeping whichever form it had. No configuration is stereogenic on a bond such a cycle can shift, so validate_stereo() drops the sign and the two shift drawings converge whether or not either states one.
Beside it: a ring that lent its double bonds takes them back before thiele(), a ring that borrows every double bond it has is counted rather than looked at, a donor flanked by two donors is not a Kekule aromatic, an ion of carbon that states its hydrogen has no room for a ring double, a substituted nitrogen carries the charge where it cannot carry the hydrogen, and two amidines sharing a nitrogen are one placement ranked in one frame.
Documentation. docs/stereo.rst is new: the anchor-and-owners rule, the per-format encoding table, and every log row on the read and the write side. docs/pach.rst gains the bond-group block, docs/molecule.rst the cip stage and the two records a narrower arena reports through from_bytes, docs/standardize.rst the kekule-form stage.
3.1
A feature release. Explicit absolute-stereo labels survive the SMILES/CXSMILES round trip, standardize() completes a one-coordinate organozinc or Grignard, and the group corpus names the persistent radicals. Nothing is removed and nothing in the canonical form moves — 3.0.2's keys are 3.1's keys.
An explicit ABS label is read and written back. A stored descriptor is two statements about its centre: somebody's completed determination, and that the centre is absolutely configured, nothing determining a descriptor for a member of an OR or AND collection. 3.1 takes the second, so the label survives a reader that has no use for the letter:
| 3.0.2 | 3.1 | |
|---|---|---|
F[C@H](Cl)Br {A1=R} → stereo_groups() |
{} |
{(ABS, 0): [2]} |
| … and writes | [C@@H](F)(Cl)Br |
[C@@H](F)(Cl)Br |a:0| |
F[C@H](Cl)Br |a:1| writes |
[C@@H](F)(Cl)Br |
[C@@H](F)(Cl)Br |a:0| |
F[C@H](Cl)Br writes |
[C@@H](F)(Cl)Br |
unchanged |
The collection is the reader's inference, not the input's words, so it is drawn only where there is a configuration to be absolute about: a @?, a q descriptor, a descriptor on a methyl carbon and a descriptor beside an o1:/&1: field are each left as they were. On the write side only an explicit ABS kind reaches the field, which is why a plain configured centre still writes bare.
ABS is not part of the canonical form, so the labelled and the unlabelled drawing of one compound still compare and hash equal. The cost lands in exactly one place: one compound now has two strings, which a cache keyed on the text sees and a cache keyed on the container does not.
standardize() completes a one-coordinate organozinc or Grignard. A drawing that states the ion pair, or drops a sign, or leaves the halide out altogether reaches the reagent:
| input | canonicalize() |
|---|---|
CC[Zn+].[Cl-] |
C(C)[Zn]Cl |
CC[Zn].[Cl] |
C(C)[Zn]Cl |
CC[Zn+].[Cl-].[Br-] |
C(C)[Zn]Cl.[Br-] |
CC[Zn] |
C(C)[Zn+] |
The pairing does not depend on input atom order, which is why this is a stage in chython/chemistry/_organometallics.py and not a standardize_metals.tsv row: a rule table applies whichever match the isomorphism search returned first, and the table writes dative contacts where this writes a polar covalent σ M–C bond. Halides are ordered Cl > Br > I > F and metals by canonical rank, so several reagents sharing fewer halides pair one each and every permutation of the input gives one answer. A metal with no halide anywhere is charged instead — zinc and magnesium alike — recorded under its own organometallics:charge id, so a consumer can decline that weaker claim, the halide nobody drew, and keep the join. It is the one stage in canonicalize() that moves the net charge, and it says so in the log.
Downstream the join is what lets a reagent name itself: CC[Zn+].[Cl-] answers metalate_carbanion before and alkyl_zinc after.
The corpus names the persistent radicals. functional.tsv goes 249 → 253 rows — nitroxide, hindered aryloxyl, hydrazyl, triarylmethyl radical. The substituents are what make the radical isolable, so that is what the rows require: D3,D4 on both α carbons of a nitroxide, a quaternary carbon at both ortho positions of an aryloxyl, and the unhindered member of each pair is a decoy the suite executes. The radical state is the CXSMARTS |^1:0| tail, there being no in-bracket primitive, so each row covers exactly one radical atom — which is what lets a caller compare the named count against the record's radical count. salts.tsv goes 109 → 110 species with 3-hydroxy-2-naphthoic, the same formula as salts:xinafoic at a different constitution, which the key mechanism keeps apart.
Rule ids stay append-only: functional:250 through 253 are new and no existing id moved.
docs/standardize.rst gains "Screening a Record". A charge that survives the pipeline is a charge that could not be paired off — neutralize() moves a proton between components and never creates or destroys one, so a species with nowhere to put its charge keeps it while a salt drawn apart does not. The radical screen is the count comparison above. What is deliberately absent is named as per-collection policy rather than library behaviour: element allow-lists, component caps and counterion size caps. [C-]#C.[Na+] and [CH3-].[Na+] both keep their charges, so that discrimination stays the caller's.
3.0.2
A fix in the reaction ML views. Packaging, the reactor and the molecule views are unchanged.
Leaving and arriving atoms hold a row in the transition state. The union was keyed by map number, so an atom present on one side only had either no key or no partner and was left out — taking its bonds with it. smiles('N1CC1') @ smiles('c1ccccc1Br'), read through modeling_view():
| 3.0.1 | 3.0.2 | |
|---|---|---|
| rows | 9 | 11 |
| the C–Br carbon | (6, 0, 2, 0, 3) |
(6, 0, 3, 0, 3) |
| the bromine | absent | (35, 0, 1, 0, 0) leaving, (35, 1, 0, 1, 0) arriving as HBr |
| bonds whose order moves | {(6, 7): (0, 1)} |
{(-1, 6): (1, 0), (6, 7): (0, 1)} |
So a substitution stated a bond appearing out of nothing, and the aryl carbon reported a reactant-side neighbour it never had.
The union is keyed on atoms. An atom on one side only holds a row on the side it came from, and on the far side counts only the neighbours that left or arrived with it: an attachment atom is decremented by a leaving group's bonds to the retained part and incremented by an installed one's, and a byproduct still reads as the molecule it is.
Being on one side only is not the same thing as being unnumbered. Whether the record numbered the atom changes its key and nothing else — the element, hydrogen, degree and bond columns are identical for [CH3:1][Br:2]>>[CH4:1] and [CH3:1]Br>>[CH4:1]. ReactionModelingView.states keys an unnumbered atom -1 onwards in union order, and unmapped still counts it, so a consumer that requires a fully mapped record checks the same attribute it always did.
mol_state_view and mol_transition_view are untouched, so a molecule's transition view still emits both halves equal and molecules and reactions share one vocabulary. docs/ml.rst gains "What a record can state" — where a leaving atom, an arriving atom, a hydrogen count change, a degree change and a bond order change each land — and the tests cover those five and their combinations.
3.0.1
Packaging fixes for the 3.0 release. No library code changed.
Linux wheels are published again. sys_tags() is ordered best-first, and since packaging 26.3 the best Linux tag is the native linux_x86_64 rather than a manylinux one (packaging #160) — so the release workflow's retag step computed the tag PyPI rejects, renamed nothing, and reported success. The tag is now selected by its manylinux prefix, which is order-independent.
Linux wheels are about a third of the size. Linux was the only platform shipping DWARF inside its binaries. Measured on 3.0's cp312 Linux wheel:
| shipped | of which .debug* |
.text |
|
|---|---|---|---|
_core.so |
20.82 MB | 17.50 MB (84%) | 2.52 MB |
libinchi.so |
4.36 MB | 3.05 MB (70%) | 1.00 MB |
The code is not bigger — Windows ships the same core as a 2.18 MB .pyd, and each slice of the universal2 macOS wheel is 3.43 MB. Two independent causes: distutils compiles extensions with CPython's own CFLAGS, which carry -g; and CMAKE_BUILD_TYPE=Release does not reach the -g that InChI's own CMakeLists adds through target_compile_options. Fixed with -g0 and a link-time strip respectively, on Linux only — clang emits a separate .dSYM and MSVC a separate .pdb, so neither ever reached those wheels. .symtab survives, so C-level backtraces keep their function names.
Also: the wheel matrix no longer cancels its other rows when one fails, since twine --skip-existing makes each upload idempotent.
3.0
Chython 3.0
Chython 3.0 is a ground-up rewrite built around a new arena-based compiled core. It delivers major performance improvements, a smaller and more predictable architecture, explicit mutation and repair workflows, and a redesigned API.
This is a breaking release: the Chython 2 API has been removed rather than deprecated.
Highlights
- A single compiled Cython extension:
chython.core._core - Contiguous arena storage for atoms and bonds
- Stable atom IDs that survive edits
- Explicit edit sessions for mutation
- Per-component canonicalization
- Compiled SMILES, SMARTS, isomorphism, ring, stereo, fingerprint, InChI, and PACH implementations
- Generated element, isotope, and valence tables
- Explicit handling of invalid or incomplete chemical input
- New layered architecture with enforced dependency boundaries
- Wheels for CPython 3.10–3.14 across Linux, macOS, and Windows
New compiled core
chython.core._core is Chython's only extension and is built as a single Cython translation unit. Atoms and bonds are stored in one contiguous arena buffer rather than Python dictionaries.
Atoms are addressed by stable IDs that survive edits. Atom-to-atom mapping is stored independently in map_number, so changing a mapping no longer requires renumbering the structure.
The compiled core includes:
- SMILES and SMARTS reading and writing
- Substructure search and isomorphism
- Ring perception
- Kekulization and Thiele aromatization
- Canonicalization
- Stereochemistry
- Linear and Morgan fingerprints
- InChI
- PACH
Canonical forms are computed per component. As a result, salts, formulations, and other dotted records scale with their individual components rather than with the size of the complete mixture.
Performance
The following results compare Chython 3.0 with Chython 2.24 on an Apple M4 Pro using Python 3.10.11.
The benchmark covers 21 public compounds and reports the fastest of 15 passes. Chython 2.24 runs in a separate isolated interpreter under -I, preventing either version from being measured against an already loaded copy of itself.
| Operation | Chython 3.0 | Chython 2.24 | Speedup |
|---|---|---|---|
| SMILES parsing | 5.1 µs | 206 µs | 40× |
| Parse and canonicalize | 65 µs | 941 µs | 15× |
| Standardize | 13 µs | 246 µs | 19× |
| Linear fingerprint | 7.0 µs | 116 µs | 17× |
| Morgan fingerprint | 4.4 µs | 49 µs | 11× |
| Run three SMARTS queries against one record | 0.49 µs | 6.4 µs | 13× |
| Parse and canonicalize a 40-component mixture | 2.0 ms | 91 ms | 47× |
Reproduce the results with:
python bench/bench_release_3_0.pyThe script launches Chython 2.24 separately and prints the comparison table.
Benchmark notes
The
standardizerow does not represent identical work in both versions. Chython 2.24 runs the complete repair pipeline, while Chython 3.0 runs one pass of that pipeline. The parse-and-canonicalize benchmark runs the full pipeline in both versions.Speedup ratios should remain broadly comparable across systems. Absolute timings are specific to the benchmark machine.
Explicit mutation
Containers are sealed by default. Mutations must be performed inside an edit session:
with molecule.edit() as editor:
...Derived data is recomputed when the session is sealed. This keeps cached and computed state synchronized without requiring callers to manage invalidation manually.
Input is preserved, not silently repaired
Readers no longer reject records solely because they are chemically invalid or incomplete.
Illegal valences, nonsensical charges, and underivable hydrogen counts are stored and logged. Unknown hydrogen counts use the reserved H_UNKNOWN value instead of silently becoming zero.
Chemical repair is an explicit post-processing step:
kekule()standardize()fix_resonance()thiele()
Structures are refused only at boundaries where a valid chemical answer is required.
Generated chemical data
Element, isotope, and valence tables are generated as compiled C tables from:
elements.tsvisotopes.tsvvalence_rules.tsv
The test suite regenerates these tables from their TSV sources and verifies the results, preventing source data and generated code from drifting apart.
New package architecture
The package is organized into layers with enforced dependency direction:
chemistrycontains chemical knowledge and the passes that apply it.reactionscontains SMIRKS templates, functional and protective-group corpora, and mapping reconstruction.formats,depict, andinteropprovide higher-level functionality.
Internal modules do not import the public façade, and dependencies do not reverse direction. Architectural tests enforce both rules.
Functionality is attached to the sealed container through injection rather than inheritance.
Breaking changes
Chython 3.0 removes the Chython 2 API. The following packages no longer exist:
algorithms/containers/files/reactor/periodictable/utils/transformation/
Architectural tests ensure that these legacy names cannot become importable again.
Atom IDs and mapping
A Chython 2 remap() call becomes set_map_number() when it was used to change atom-to-atom mapping:
molecule.set_map_number(...)There is no replacement when remap() was used only to renumber atoms. Chython 3.0 uses stable atom IDs and stores map_number separately.
SMARTS z3
Chython 2 and Chython 3 assign different meanings to z3. Review every use of z3 when porting SMARTS patterns.
Fingerprints
Fingerprint bits are not compatible with those generated by Chython 2.24. Recompute stored fingerprints and rebuild any indexes or models that depend on them.
Machine-learning interface
torch_device has been removed. The machine-learning surface now uses NumPy arrays.
CGR removal
CGRContainer has been removed. ReactionModelingView is the CGR-free interface consumed by chytorch.
Element classes
Metaclass-generated element classes have been removed. Element, isotope, and valence information now comes from generated compiled tables.
Chython 2 compatibility
Chython 2.24 is retained only as an out-of-process differential oracle for validation. Importing or consulting V2 from the Chython 3 runtime is not supported.
Installation
Install the core package with:
pip install chythonThe core installation contains approximately 7.5 MB of runtime files, does not require NumPy, and includes built-in InChI support.
Prebuilt wheels are available for CPython 3.10–3.14 on:
- Linux x86-64
- Linux arm64
- macOS
- Windows
A source distribution is also available.
Fingerprints, array APIs, and machine-learning views require the optional ml dependencies:
pip install 'chython[ml]'Documentation and validation
Documentation lives in docs/. Every Python example is executed as part of validation, and every figure is regenerated and compared with its committed version.
Chython 3.0 is covered by:
- 10,911 tests
- 3 minutes 17 seconds of test execution
- 91% coverage of the Python layers
- Differential suites against Chython 2.24 and RDKit
The compiled core is validated through behavioral agreement with the differential oracles rather than source-line coverage.
Chython 3.0 replaces the internals, execution model, and public API in one release. Existing applications should treat the upgrade as a migration rather than a drop-in version change.
2.24
Changes since 2.22. Includes the interim 2.23 and 2.23.1 bumps.
Breaking
functional_fingerprint layout and width changed — stored fingerprints must be recomputed.
Protective groups were absent from the vector entirely: their patterns live in a separate rule set
and no functional pattern covers carbamates, silyl ethers or acetals, so a protected molecule
fingerprinted identically to its free analogue. The layout is now
| bits | content |
|---|---|
| 0–34 | descriptors (rings, degree, aromatic ratio, sp3 fraction, MW, rotatable bonds) |
| 35–137 | protective group presence (one bit per pattern in protective_rules) |
| 138–341 | functional group presence (one bit per pattern in functional_rules) |
Currently 342 bits / 43 bytes, up from a fixed 256 bits / 32 bytes. Offsets and width are now
derived from the rule-set sizes rather than hard-coded, so the vector grows as patterns are added;
protective bits sit ahead of functional ones because functional_rules is the frequently extended
set, so appending there grows the tail without moving a protective bit. The format carries no
version field, so there is no migration path — recompute.
- New
fingerprint_size() -> intreturns the current width in bytes. fingerprint_schema()now also contains protective group names. Protective and functional names
are disjoint, so both share one flat namespace and a bit is looked up by the same name
protective_groups/functional_groupsreport.- Bits read the cached
protective_groupsproperty, so they agree withremove_protection,
including its overlap filtering of nested sub-patterns. - Both rule dicts are lazy proxies and the layout resolves on first use, not at import (forcing them
at import would close a cycle).
Sticky-fragment bond ownership: right-open ends no longer emit their trailing bond.
The junction bond now belongs to the left partner. A.sticky_right + B.sticky_left and
frag-linker-frag chains concatenate to exactly one bond token per junction, instead of "2 bonds in a
row" (invalid SMILES). Any code that stripped or compensated for the duplicate bond needs updating.
urea / thiourea functional-group patterns broadened from unsubstituted-only (D1 nitrogens)
to any substituted urea, so functional_groups now reports them on N-substituted ureas. Biginelli
moved onto the new nh_urea / nh_thiourea subsets (see below), so its behaviour is unchanged.
Added
Reaction templates — six new named reactions: epoxide_opening, knorr_pyrazolone,
acylhydrazone, sulfonylhydrazone, oxadiazole, hydrazide_formation. knorr_pyrazole extended
to aryl hydrazines; snar accepts tertiary alkoxides. 890 template rules across 77 named
reactions.
New functional groups:
aziridine_nh— isomorphism is induced, so the branchedsecondary_aminepattern can never match
a 3-ring (its two carbons are bonded to each other). Aziridine gets a one-atom N pattern and is
wired into every site that accepts a secondary amine: amidation, aminolysis, Buchwald-Hartwig,
Chan-Lam, carbamoylation, urea formation, sulfonamide formation, reductive amination,
N-alkylation, SNAr, epoxide opening. Also gets analkyl_aminerole cap.succinimidyl_carbonate,aryl_carbonate,imidazolyl_carbonate— activated-carbonate
chloroformate surrogates (NHS/DSC, phenyl / p-nitrophenyl / pentafluorophenyl, CDI adduct),
numbered identically tochloroformateso they drop straight intocarbamoylation.terminal_epoxide— pins the attacked carbon to the CH2, giving base-mediated regiochemistry
(attack at the less hindered carbon) instead of an arbitrary one; the symmetricepoxidepattern
has its two mappings collapsed by the automorphism filter.sulfonylhydrazide— the reagent that makes the already-supportedsulfonylhydrazone, which
previously had no route in (and so no route to the existingbamford_stevenstransformation).aryl_hydrazine_ortho_ch— the free-ortho-CH variant, which onlyfischer_indoleneeds;
aryl_hydrazineitself relaxed to plain Ar-NH-NH2 so hydrazines with both ortho positions blocked
(or hetaryl ones with no ortho CH at all) still condense.nh_urea,nh_thiourea— acyclic, N-H on both nitrogens, i.e. the condensation-capable subset
used by Biginelli. Excludes N,N-disubstituted ureas and ureas whose nitrogens already sit in a
ring, neither of which gives a DHPM.
tries and hydrogens on sticky_fragments() / sticky_linkers(), forwarded to every
sticky_smiles call. The right terminal is serialized by a bounded random traversal (default 10
tries) that can exhaust on branched or symmetric skeletons and silently drop the compound; callers
can now raise the limit.
Fixed
reconstruct_mapping now handles salts. A logged reactant routinely bundles a spectator
counterion with the reactive species (K/Na trifluoroborate, amine.HCl, sodium alkoxide) in one
container. Handed the whole thing, the reactor faithfully carries the spectator into the generated
product, which then can never equal the salt-free target, and a real match is lost. Components are
now offered as extra candidates in addition to the intact container, never instead of it — the
counterion is sometimes retained on both sides (amine.HCl -> amine'.HCl) and that only reconstructs
from the intact form.
Masked atoms no longer drive couplings. A group revealed by an incidental deprotection is barred
from participating in the coupling a sticky fragment represents, in both ways it can participate:
as the attachment site (the fresh [At] cap hangs off it — addition/substitution roles such as a
freed amine as an alkyl_amine handle) and as a leaving group (the transform consumes it while
capping a neighbour — deaminative/deoxy/decarboxy roles such as the same freed amine as an
alkyl_deamino leaving group). Previously only the first case was checked.
Biginelli product template now spells out the surviving ester (ester carbon, its carbonyl O, its
alkoxy O). Pattern atoms left out of a product template are deleted, so the ester was being stripped
from the DHPM; only the two oxygens genuinely lost as water are omitted now.