Source code for vibesketch.document.rgfile

"""R-group files: R-group cores and their definitions in one V3000 molfile (#90).

The format is the CTfile V3000 R-group query (BIOVIA, "CTfile Formats",
chapter "Rgroup query"): the root connection table, whose R-group atoms
are ``R#`` with ``RGROUPS=(1 n)``, followed by one block per definition::

    M  V30 BEGIN RGROUP 1
    M  V30 RLOGIC 2 1 1-3
    M  V30 BEGIN CTAB
    ... one connection table per member, its attachment points ATTCHPT=1 ...
    M  V30 END CTAB
    M  V30 END RGROUP

``RLOGIC`` gives the R-group this one requires (0 for none), whether
unsubstituted positions are hydrogen (rest-H) and the occurrence. RDKit
reads and writes each connection table, but not the RGROUP blocks, which
this module adds and splits off (maintainer decisions, 2026-10-24).
"""

from __future__ import annotations

import re
from dataclasses import dataclass, field
from typing import Any

__all__ = ["Definition", "RgfileUnreadable", "read_rgfile", "rgfile", "is_rgfile"]

_BEGIN = "M  V30 BEGIN RGROUP"
_HEADER = "\n  vibesketch\n\n  0  0  0     0  0            999 V3000\n"
_RLOGIC = re.compile(r'M  V30 RLOGIC\s+(\d+)\s+(\d+)\s*(?:"([^"]*)"|(.*?))\s*$')


[docs] class RgfileUnreadable(ValueError): """The R-group blocks of a molfile could not be read."""
[docs] @dataclass class Definition: """One R-group's definition: its members and its logic.""" number: int members: list[Any] = field(default_factory=list) occurrence: str = "" rest_h: bool = False if_then: int = 0
[docs] def is_rgfile(text: str) -> bool: return _BEGIN in text
def _ctab(mol: Any) -> list[str]: """The ``BEGIN CTAB`` to ``END CTAB`` lines of ``mol`` as a V3000 molfile.""" from rdkit import Chem lines = str(Chem.MolToV3KMolBlock(mol)).splitlines() start = lines.index("M V30 BEGIN CTAB") end = lines.index("M V30 END CTAB") return lines[start : end + 1]
[docs] def rgfile(root: Any, definitions: list[Definition]) -> str: """``root`` and the RGROUP blocks of ``definitions`` as one V3000 molfile.""" from rdkit import Chem block = str(Chem.MolToV3KMolBlock(root)) lines = block[: block.rindex("M END")].splitlines() for definition in definitions: # Unquoted: Ketcher's reader takes the rest of the line as the occurrence. occurrence = " ".join(definition.occurrence.replace('"', "").split()) lines.append(f"{_BEGIN} {definition.number}") logic = f"M V30 RLOGIC {definition.if_then} {int(definition.rest_h)} {occurrence}" lines.append(logic.rstrip()) for member in definition.members: lines.extend(_ctab(member)) lines.append("M V30 END RGROUP") lines.append("M END") return "\n".join(lines) + "\n"
[docs] def read_rgfile(text: str) -> tuple[Any, list[Definition]]: """The root molecule and the definitions of an R-group file. RDKit reads the root and each member; its ``R#`` atoms keep their numbers and the members' ``ATTCHPT`` their attachment points. """ from rdkit import Chem lines = text.splitlines() first = next(i for i, line in enumerate(lines) if line.startswith(_BEGIN)) root = Chem.MolFromMolBlock("\n".join([*lines[:first], "M END"]) + "\n", sanitize=False) if root is None: raise RgfileUnreadable("RDKit could not read the R-group file's core") definitions: list[Definition] = [] current: Definition | None = None ctab: list[str] | None = None for number, line in enumerate(lines[first:], start=first + 1): if line.startswith(_BEGIN): if current is not None: raise RgfileUnreadable(f"line {number}: an RGROUP block inside another") value = line[len(_BEGIN) :].strip() if not value.isdigit() or int(value) < 1: raise RgfileUnreadable(f"line {number}: {value!r} is not an R-group number") current = Definition(int(value)) elif line.startswith("M V30 RLOGIC"): logic = _RLOGIC.match(line) if current is None or logic is None: raise RgfileUnreadable(f"line {number}: cannot read {line.strip()!r}") current.if_then, current.rest_h = int(logic.group(1)), logic.group(2) == "1" current.occurrence = (logic.group(3) or logic.group(4) or "").strip() elif line == "M V30 BEGIN CTAB": ctab = [line] elif line == "M V30 END CTAB" and ctab is not None: ctab.append(line) member = Chem.MolFromMolBlock(_HEADER + "\n".join([*ctab, "M END"]) + "\n") if current is None or member is None: raise RgfileUnreadable(f"line {number}: cannot read an R-group member") current.members.append(member) ctab = None elif ctab is not None: ctab.append(line) elif line == "M V30 END RGROUP": if current is None or not current.members: raise RgfileUnreadable(f"line {number}: an R-group without members") definitions.append(current) current = None if current is not None or ctab is not None: raise RgfileUnreadable("the R-group file ends inside an RGROUP block") return root, definitions