Source code for vibeview.importers

"""Third-party importer discovery for vibe-view.

Importer packages register one object in the ``vibeview.importers`` entry
point group.  The object may be an :class:`ImporterSpec` or a zero-argument
factory returning one.  Keeping the contract in this small, renderer-free
module lets chemistry codes add file support without depending on vibe-view's
web or desktop stack.
"""

from __future__ import annotations

import contextlib
import io
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
from functools import lru_cache
from importlib import metadata
from pathlib import Path
from typing import BinaryIO, cast

IMPORTER_API_VERSION = 1
IMPORTER_ENTRY_POINT_GROUP = "vibeview.importers"

# Plugin names share the ``--from`` namespace with converters shipped by
# vibe-view. Keeping this list in the lightweight contract module lets plugin
# discovery reject an ambiguous registration without importing NumPy or any
# of the renderer/converter stack.
RESERVED_FORMAT_NAMES = frozenset(
    {
        "ase",
        "cif",
        "cube",
        "mrc",
        "gjf",
        "gro",
        "mol2",
        "orca",
        "pdb",
        "mmcif",
        "fchk",
        "molden",
        "zmatrix",
        "py",
        "qvf",
        "results",
        "sdf",
        "trexio",
        "turbomole",
        "xyz",
    }
)

ImporterOutput = bytes | bytearray | BinaryIO
ImporterConverter = Callable[[Path], ImporterOutput]
ImporterProbe = Callable[[Path], bool]


[docs] class ImporterError(ValueError): """An importer could not be loaded or did not produce a valid QVF."""
[docs] @dataclass(frozen=True) class ImporterSpec: """Versioned contract implemented by a third-party file importer. ``format_name`` is the stable CLI name used by ``--from``. Extensions include their leading dot and are matched case-insensitively. Exact ``stems`` cover extensionless formats such as ``POSCAR``. A custom ``probe`` may inspect a path when suffix matching is insufficient. ``convert`` must return a complete QVF archive as bytes or a readable binary file object positioned anywhere; vibe-view rewinds and validates the archive before it reaches the caller. """ format_name: str description: str extensions: tuple[str, ...] convert: ImporterConverter stems: tuple[str, ...] = () probe: ImporterProbe | None = None data_kinds: tuple[str, ...] = ("structure",) api_version: int = IMPORTER_API_VERSION
[docs] def matches(self, path: Path) -> bool: """Return whether this importer accepts ``path``.""" lower_name = path.name.lower() if any(lower_name.endswith(suffix.lower()) for suffix in self.extensions): return True if path.name in self.stems: return True return bool(self.probe and self.probe(path))
[docs] @dataclass(frozen=True) class ImporterStatus: """One discovered entry point, including isolated load failures.""" entry_point: str distribution: str | None spec: ImporterSpec | None error: str | None = None @property def available(self) -> bool: return self.spec is not None and self.error is None
def _entry_points() -> tuple[metadata.EntryPoint, ...]: """Return importer entry points across supported importlib APIs.""" discovered = metadata.entry_points() selected: Sequence[metadata.EntryPoint] if hasattr(discovered, "select"): selected = discovered.select(group=IMPORTER_ENTRY_POINT_GROUP) else: # pragma: no cover - compatibility with older importlib-metadata legacy = cast(Mapping[str, Sequence[metadata.EntryPoint]], discovered) selected = legacy.get(IMPORTER_ENTRY_POINT_GROUP, ()) return tuple(sorted(selected, key=lambda ep: ep.name)) def _distribution_name(entry_point: metadata.EntryPoint) -> str | None: dist = getattr(entry_point, "dist", None) if dist is None: return None try: return dist.metadata.get("Name") or str(dist) except Exception: # pragma: no cover - defensive for third-party metadata return str(dist) def _validate_spec(value: object, entry_point_name: str) -> ImporterSpec: if callable(value) and not isinstance(value, ImporterSpec): value = value() if not isinstance(value, ImporterSpec): raise TypeError("entry point must expose ImporterSpec or a zero-argument factory") if type(value.api_version) is not int: raise TypeError("api_version must be an integer") if value.api_version != IMPORTER_API_VERSION: raise ValueError( f"unsupported importer API {value.api_version}; " f"vibe-view supports {IMPORTER_API_VERSION}" ) if not isinstance(value.format_name, str): raise TypeError("format_name must be a string") if not value.format_name or any(ch.isspace() for ch in value.format_name): raise ValueError("format_name must be a non-empty name without whitespace") if value.format_name.casefold() in RESERVED_FORMAT_NAMES: raise ValueError(f"format_name {value.format_name!r} is reserved by a built-in importer") if value.format_name != entry_point_name: raise ValueError( f"format_name {value.format_name!r} must match entry point name {entry_point_name!r}" ) if not isinstance(value.description, str): raise TypeError("description must be a string") if not value.description.strip(): raise ValueError("description must be non-empty") if not isinstance(value.extensions, tuple): raise TypeError("extensions must be a tuple of strings") if not isinstance(value.stems, tuple): raise TypeError("stems must be a tuple of strings") if not isinstance(value.data_kinds, tuple): raise TypeError("data_kinds must be a tuple of strings") if not callable(value.convert): raise TypeError("convert must be callable") if value.probe is not None and not callable(value.probe): raise TypeError("probe must be callable or None") if not value.extensions and not value.stems and value.probe is None: raise ValueError("declare at least one extension, stem, or probe") for suffix in value.extensions: if not isinstance(suffix, str): raise TypeError("extensions must contain only strings") if not suffix.startswith("."): raise ValueError(f"extension {suffix!r} must start with '.'") if len(suffix) == 1 or "/" in suffix or "\\" in suffix: raise ValueError(f"extension {suffix!r} is not a valid filename suffix") normalized_extensions = [suffix.casefold() for suffix in value.extensions] if len(set(normalized_extensions)) != len(normalized_extensions): raise ValueError("extensions must not contain duplicate suffixes") for stem in value.stems: if not isinstance(stem, str): raise TypeError("stems must contain only strings") if not stem or "/" in stem or "\\" in stem: raise ValueError(f"stem {stem!r} must be a non-empty filename") if len(set(value.stems)) != len(value.stems): raise ValueError("stems must not contain duplicate filenames") if not value.data_kinds: raise ValueError("data_kinds must declare at least one QVF data kind") for data_kind in value.data_kinds: if not isinstance(data_kind, str): raise TypeError("data_kinds must contain only strings") if not data_kind.strip(): raise ValueError("data_kinds must contain only non-empty strings") return value def _error_text(exc: BaseException) -> str: detail = str(exc) if detail: return f"{type(exc).__name__}: {detail}" return type(exc).__name__ def _claim_conflicts( statuses: list[ImporterStatus], ) -> dict[str, tuple[str, ...]]: """Return deterministic plugin-to-plugin extension/stem conflicts.""" owners: dict[tuple[str, str], list[str]] = {} for status in statuses: if status.spec is None: continue for suffix in status.spec.extensions: owners.setdefault(("extension", suffix.casefold()), []).append(status.entry_point) for stem in status.spec.stems: owners.setdefault(("stem", stem), []).append(status.entry_point) conflicts: dict[str, list[str]] = {} for (claim_kind, claim), claim_owners in sorted(owners.items()): unique_owners = sorted(set(claim_owners)) if len(unique_owners) < 2: continue for owner in unique_owners: others = ", ".join(repr(name) for name in unique_owners if name != owner) conflicts.setdefault(owner, []).append( f"{claim_kind} {claim!r} is also claimed by {others}" ) return {name: tuple(messages) for name, messages in conflicts.items()}
[docs] @lru_cache(maxsize=1) def discover_importers() -> tuple[ImporterStatus, ...]: """Load installed importer plugins, isolating failures per plugin.""" statuses: list[ImporterStatus] = [] grouped: dict[str, list[metadata.EntryPoint]] = {} for entry_point in _entry_points(): grouped.setdefault(entry_point.name, []).append(entry_point) for entry_point_name in sorted(grouped): entries = sorted( grouped[entry_point_name], key=lambda entry_point: _distribution_name(entry_point) or "", ) if len(entries) > 1: registrants = ", ".join( repr(_distribution_name(entry_point) or "unknown distribution") for entry_point in entries ) error = f"duplicate entry point name {entry_point_name!r}; registered by {registrants}" for entry_point in entries: statuses.append( ImporterStatus( entry_point=entry_point_name, distribution=_distribution_name(entry_point), spec=None, error=error, ) ) continue entry_point = entries[0] distribution = _distribution_name(entry_point) try: # Entry-point modules and factories are third-party code. Keep a # stray print from corrupting `vibe-view formats --json`; load # failures remain visible through ImporterStatus.error. with ( contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()), ): spec = _validate_spec(entry_point.load(), entry_point.name) except (Exception, SystemExit) as exc: statuses.append( ImporterStatus( entry_point=entry_point.name, distribution=distribution, spec=None, error=_error_text(exc), ) ) continue statuses.append( ImporterStatus( entry_point=entry_point.name, distribution=distribution, spec=spec, ) ) conflicts = _claim_conflicts(statuses) resolved: list[ImporterStatus] = [] for status in statuses: messages = conflicts.get(status.entry_point) if messages is None: resolved.append(status) continue resolved.append( ImporterStatus( entry_point=status.entry_point, distribution=status.distribution, spec=None, error="conflicting importer claims: " + "; ".join(messages), ) ) return tuple( sorted( resolved, key=lambda status: (status.entry_point, status.distribution or ""), ) )
[docs] def clear_importer_cache() -> None: """Clear discovery state after installing a plugin in this process.""" discover_importers.cache_clear()
[docs] def available_importers() -> tuple[ImporterSpec, ...]: """Return successfully loaded importers in deterministic order.""" return tuple(status.spec for status in discover_importers() if status.spec is not None)
[docs] def get_importer(format_name: str) -> ImporterSpec | None: """Return the installed importer named ``format_name``.""" normalized = format_name.removeprefix("plugin:") return next( (spec for spec in available_importers() if spec.format_name == normalized), None, )
[docs] def detect_importer(path: str | Path) -> ImporterSpec | None: """Return the first installed plugin accepting ``path``.""" candidate = Path(path) for spec in available_importers(): try: if spec.matches(candidate): return spec except (Exception, SystemExit): # A broken probe must not stop built-in formats or other plugins # from being considered. The plugin remains visible in `formats`. continue return None
def _as_bytes(output: ImporterOutput, format_name: str) -> bytes: if isinstance(output, (bytes, bytearray)): return bytes(output) close = getattr(output, "close", None) try: read = getattr(output, "read", None) if not callable(read): raise ImporterError( f"importer {format_name!r} returned {type(output).__name__}; " "expected bytes or a readable binary file object" ) seek = getattr(output, "seek", None) if callable(seek): seek(0) payload = read() if not isinstance(payload, (bytes, bytearray)): raise ImporterError(f"importer {format_name!r} returned non-binary data") return bytes(payload) finally: if callable(close): # The complete payload has already been copied into viewer-owned # memory. A broken plugin cleanup hook must not terminate the host. with contextlib.suppress(Exception, SystemExit): close()
[docs] def convert_with_importer(spec: ImporterSpec, path: str | Path) -> io.BytesIO: """Run one plugin and validate its QVF output before returning it.""" source = Path(path) try: payload = _as_bytes(spec.convert(source), spec.format_name) except ImporterError: raise except (Exception, SystemExit) as exc: raise ImporterError( f"importer {spec.format_name!r} could not read {source.name}: {exc}" ) from exc from vibeview.qvf import QVFError, QVFReader buffer = io.BytesIO(payload) reader: QVFReader | None = None try: reader = QVFReader(buffer) for section in reader.sections: for member in section.members.values(): reader._verify_and_read(member) except QVFError as exc: raise ImporterError( f"importer {spec.format_name!r} produced an invalid QVF: {exc}" ) from exc finally: if reader is not None: reader.close() buffer.seek(0) return buffer