Source code for vibeview.renderer_plugins

"""Third-party section renderers for vibe-view (extension API v1).

A renderer package registers one object in the ``vibeview.renderers`` entry
point group, named after the section kind it renders. The object is a
:class:`RendererSpec` or a zero-argument factory returning one. The kind then
classifies as rendered everywhere vibe-view reports a section: the browser
lists it with the plugin's title and icon and shows the plugin's HTML in the
details panel, ``vibe-view info`` and the startup banner count it as rendered,
a ``critical`` section of that kind no longer blocks the open, and the
terminal shows its generic section pane.

Like :mod:`vibeview.importers`, this module is the whole contract and imports
nothing heavy: discovery reads entry-point metadata and loads the plugin
objects, never the viewer stack.

Contract, version 1 (``RENDERER_API_VERSION``); vibe-view 3.x keeps it
compatible:

* ``RendererSpec`` fields as declared here. New fields are added only with
  defaults.
* The renderer class is constructed as ``renderer(section, reader)`` with the
  :class:`~vibeview.qvf.Section` and the :class:`~vibeview.qvf.QVFReader` that
  owns it. ``load()`` returns the section's data, and ``render_to_html()``,
  called with no arguments, returns the HTML document the details panel shows.
  Subclassing :class:`vibeview.renderers.BaseRenderer` is optional.
* A renderer reads its section through ``section.id``, ``section.kind``,
  ``section.members``, ``section.model_extra`` and the reader's verified
  member methods ``read_json_member``, ``read_text_member`` and
  ``read_binary_member``.

Everything else in vibe-view, ``vibeview.app`` included, stays internal.
Representations and 3D actors are not part of version 1.
"""

from __future__ import annotations

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

from vibeview.importers import _distribution_name, _error_text

RENDERER_API_VERSION = 1
RENDERER_ENTRY_POINT_GROUP = "vibeview.renderers"

#: The sidebar icon a spec gets when it names none.
DEFAULT_PLUGIN_ICON = "mdi-puzzle-outline"

#: ``classify_section``'s detail for a kind an installed plugin renders.
PLUGIN_DETAIL = "via plugin"


[docs] class RendererPluginError(ValueError): """A renderer plugin could not be loaded or does not meet the contract."""
[docs] @dataclass(frozen=True) class RendererSpec: """Versioned contract implemented by a third-party section renderer. ``kind`` is the QVF section kind rendered, and must equal the entry point's name. A vendor kind (``x_<vendor>.<name>``) is the usual choice; a kind vibe-view renders itself cannot be claimed. ``title`` is the sidebar and panel title and ``icon`` a Material Design Icons name (``mdi-...``). ``renderer`` is the class described in the module docstring. """ kind: str title: str renderer: type icon: str = DEFAULT_PLUGIN_ICON description: str = "" api_version: int = RENDERER_API_VERSION
[docs] @dataclass(frozen=True) class RendererStatus: """One discovered entry point, including isolated load failures.""" entry_point: str distribution: str | None spec: RendererSpec | 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 renderer entry points across supported importlib APIs.""" discovered = metadata.entry_points() selected: Sequence[metadata.EntryPoint] if hasattr(discovered, "select"): selected = discovered.select(group=RENDERER_ENTRY_POINT_GROUP) else: # pragma: no cover - compatibility with older importlib-metadata legacy = cast(Mapping[str, Sequence[metadata.EntryPoint]], discovered) selected = legacy.get(RENDERER_ENTRY_POINT_GROUP, ()) return tuple(sorted(selected, key=lambda ep: ep.name)) def _builtin_kinds() -> frozenset[str]: from vibeview.kinds import RENDERED_VIA_STRUCTURE, SUPPORTED_KINDS return SUPPORTED_KINDS | RENDERED_VIA_STRUCTURE def _validate_spec(value: object, entry_point_name: str) -> RendererSpec: if callable(value) and not isinstance(value, (RendererSpec, type)): value = value() if not isinstance(value, RendererSpec): raise TypeError("entry point must expose RendererSpec 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 != RENDERER_API_VERSION: raise ValueError( f"unsupported renderer API {value.api_version}; " f"vibe-view supports {RENDERER_API_VERSION}" ) if not isinstance(value.kind, str): raise TypeError("kind must be a string") if not value.kind or any(ch.isspace() for ch in value.kind): raise ValueError("kind must be a non-empty section kind without whitespace") if value.kind != entry_point_name: raise ValueError(f"kind {value.kind!r} must match entry point name {entry_point_name!r}") if value.kind in _builtin_kinds(): raise ValueError(f"kind {value.kind!r} is rendered by vibe-view itself") if not isinstance(value.title, str): raise TypeError("title must be a string") if not value.title.strip(): raise ValueError("title must be non-empty") if not isinstance(value.icon, str): raise TypeError("icon must be a string") if not value.icon.startswith("mdi-") or len(value.icon) == len("mdi-"): raise ValueError(f"icon {value.icon!r} must be a Material Design Icons name, 'mdi-...'") if not isinstance(value.description, str): raise TypeError("description must be a string") if not isinstance(value.renderer, type): raise TypeError("renderer must be a class") for method in ("load", "render_to_html"): if not callable(getattr(value.renderer, method, None)): raise TypeError(f"renderer class must define {method}()") return value
[docs] @lru_cache(maxsize=1) def discover_renderers() -> tuple[RendererStatus, ...]: """Load installed renderer plugins, isolating failures per plugin.""" statuses: list[RendererStatus] = [] 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: # Two packages claiming one kind fail closed: neither renders it, # and both are reported, rather than one winning by install order. 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}" statuses.extend( RendererStatus( entry_point=entry_point_name, distribution=_distribution_name(entry_point), spec=None, error=error, ) for entry_point in entries ) continue entry_point = entries[0] distribution = _distribution_name(entry_point) try: # Entry-point modules and factories are third-party code: a stray # print must not corrupt `vibe-view info --json` or the banner. 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( RendererStatus( entry_point=entry_point.name, distribution=distribution, spec=None, error=_error_text(exc), ) ) continue statuses.append( RendererStatus(entry_point=entry_point.name, distribution=distribution, spec=spec) ) return tuple(statuses)
[docs] def clear_renderer_cache() -> None: """Clear discovery state after installing a plugin in this process.""" discover_renderers.cache_clear()
[docs] def available_renderers() -> tuple[RendererSpec, ...]: """Return successfully loaded renderer specs in deterministic order.""" return tuple(status.spec for status in discover_renderers() if status.spec is not None)
[docs] def get_renderer_plugin(kind: str) -> RendererSpec | None: """Return the installed renderer for ``kind``, or ``None``.""" for spec in available_renderers(): if spec.kind == kind: return spec return None
[docs] def plugin_kinds() -> frozenset[str]: """The section kinds installed plugins render. Never raises: discovery isolates each plugin, and a failure of discovery itself leaves the built-in kinds as they are. """ try: return frozenset(spec.kind for spec in available_renderers()) except Exception: # noqa: BLE001 — classification must not fail on plugins return frozenset()
[docs] def plugin_report() -> list[dict[str, object]]: """One row per discovered entry point, for ``vibe-view doctor``.""" rows: list[dict[str, object]] = [] for status in discover_renderers(): rows.append( { "kind": status.entry_point, "distribution": status.distribution, "available": status.available, "title": status.spec.title if status.spec else None, "error": status.error, } ) return rows