Source code for vibesketch.naming.opsin
"""Structures from names through OPSIN, an optional route (#94).
OPSIN, the Open Parser for Systematic IUPAC Nomenclature (D. M. Lowe,
P. T. Corbett, P. Murray-Rust and R. C. Glen, "Chemical Name to Structure:
OPSIN, an Open Source Solution", J. Chem. Inf. Model. 51 (2011) 739-753,
doi:10.1021/ci100384d), reads systematic, CAS-style and many trivial names.
It is the ``[opsin]`` extra: the OPSIN command-line jar as the ``py2opsin``
package ships it, run on a Java runtime the user already has. The jar is run
directly, the names on its standard input and the SMILES read from its
output, one line per name, so no file is written; the wrapper's own
function, which writes a temporary file into the working directory, is not
used. OPSIN works offline: nothing is looked up on the network.
"""
from __future__ import annotations
import importlib.util
import os
import shutil
import subprocess
from dataclasses import dataclass
from pathlib import Path
__all__ = ["OpsinResult", "OpsinUnavailable", "install_hint", "opsin_available", "read_name"]
#: The longest name passed to OPSIN.
MAX_NAME = 1000
#: How long one OPSIN run may take, in seconds; the first run starts the Java VM.
TIMEOUT = 60
[docs]
class OpsinUnavailable(RuntimeError):
"""OPSIN or a Java runtime to run it on is missing."""
[docs]
def install_hint() -> str:
return (
"names beyond vibe-qc's naming engine need OPSIN and a Java runtime (8 or newer): "
"install the extra with `pip install 'vibesketch[opsin]'` and a Java runtime such as "
"OpenJDK"
)
[docs]
@dataclass(frozen=True)
class OpsinResult:
"""OPSIN's SMILES for a name, or why it could not read the name."""
name: str
smiles: str | None
reason: str | None = None
def _jar() -> Path:
spec = importlib.util.find_spec("py2opsin")
locations = list(spec.submodule_search_locations or []) if spec else []
for location in locations:
found = sorted(Path(location).glob("opsin-cli-*-jar-with-dependencies.jar"))
if found:
return found[-1]
raise OpsinUnavailable(install_hint())
def _java() -> str:
home = os.environ.get("JAVA_HOME")
if home and (Path(home) / "bin" / "java").is_file():
return str(Path(home) / "bin" / "java")
found = shutil.which("java")
if found is None:
raise OpsinUnavailable(install_hint())
return found
[docs]
def opsin_available() -> bool:
"""Whether the OPSIN jar and a Java runtime are both there."""
try:
_jar()
_java()
except OpsinUnavailable:
return False
return True
def _reason(stderr: str, name: str) -> str:
"""OPSIN's explanation for ``name``, from its error output."""
for line in stderr.splitlines():
if line.startswith(name) and "unparsable" in line:
detail = line[len(name) :].strip()
return detail.split(" The following was not parseable")[0]
return "OPSIN could not read it"
[docs]
def read_name(name: str) -> OpsinResult:
"""OPSIN's structure for ``name``; raises :class:`OpsinUnavailable` without OPSIN or Java."""
name = name.strip()
if not name or len(name) > MAX_NAME or "\n" in name or "\r" in name:
return OpsinResult(name, None, f"a name is one line of at most {MAX_NAME} characters")
command = [_java(), "-jar", str(_jar()), "-osmi"]
try:
done = subprocess.run(
command,
input=name + "\n",
capture_output=True,
text=True,
encoding="utf-8",
timeout=TIMEOUT,
check=False,
)
except (OSError, subprocess.TimeoutExpired) as error:
raise OpsinUnavailable(f"OPSIN did not run: {error}. {install_hint()}") from None
if done.returncode != 0:
raise OpsinUnavailable(f"OPSIN failed: {done.stderr.strip()[:300]}")
smiles = (done.stdout.splitlines() or [""])[0].strip()
if not smiles:
return OpsinResult(name, None, _reason(done.stderr, name))
return OpsinResult(name, smiles)