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